@mohasinac/appkit 4.37.1 → 4.39.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 (172) hide show
  1. package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +20 -1
  2. package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +21 -1
  3. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts +40 -0
  4. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.js +67 -0
  5. package/dist/_internal/server/features/bundles/og.d.ts +0 -1
  6. package/dist/_internal/server/features/bundles/og.js +2 -18
  7. package/dist/_internal/server/features/catalogue/og.d.ts +0 -1
  8. package/dist/_internal/server/features/catalogue/og.js +1 -9
  9. package/dist/_internal/server/features/checkout/actions.js +18 -0
  10. package/dist/_internal/server/features/classified/og.d.ts +0 -1
  11. package/dist/_internal/server/features/classified/og.js +1 -9
  12. package/dist/_internal/server/features/digital-code/download.d.ts +60 -0
  13. package/dist/_internal/server/features/digital-code/download.js +128 -0
  14. package/dist/_internal/server/features/digital-code/og.d.ts +0 -1
  15. package/dist/_internal/server/features/digital-code/og.js +1 -9
  16. package/dist/_internal/server/features/digital-code/pool.d.ts +101 -0
  17. package/dist/_internal/server/features/digital-code/pool.js +208 -0
  18. package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
  19. package/dist/_internal/server/features/digital-code/upload.js +144 -0
  20. package/dist/_internal/server/features/grouped/og.d.ts +7 -4
  21. package/dist/_internal/server/features/grouped/og.js +7 -6
  22. package/dist/_internal/server/features/item-requests/og.d.ts +0 -1
  23. package/dist/_internal/server/features/item-requests/og.js +2 -18
  24. package/dist/_internal/server/features/live/og.d.ts +0 -1
  25. package/dist/_internal/server/features/live/og.js +1 -9
  26. package/dist/_internal/server/features/prize-draws/og.js +5 -10
  27. package/dist/_internal/server/features/products/og.js +5 -2
  28. package/dist/_internal/server/features/seo/sitemap.js +10 -2
  29. package/dist/_internal/server/features/shared/listing-adapters.d.ts +1 -2
  30. package/dist/_internal/server/features/shared/listing-adapters.js +5 -3
  31. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +40 -0
  32. package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
  33. package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
  34. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
  35. package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
  36. package/dist/_internal/server/notifications/send-recorder.js +126 -0
  37. package/dist/_internal/shared/listing-types/_registry.d.ts +31 -4
  38. package/dist/_internal/shared/listing-types/art/config.d.ts +1 -0
  39. package/dist/_internal/shared/listing-types/art/config.js +2 -0
  40. package/dist/_internal/shared/listing-types/auction/config.d.ts +1 -0
  41. package/dist/_internal/shared/listing-types/auction/config.js +3 -0
  42. package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -0
  43. package/dist/_internal/shared/listing-types/classified/config.js +3 -0
  44. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -0
  45. package/dist/_internal/shared/listing-types/digital-code/config.js +3 -0
  46. package/dist/_internal/shared/listing-types/live/config.d.ts +1 -0
  47. package/dist/_internal/shared/listing-types/live/config.js +3 -0
  48. package/dist/_internal/shared/listing-types/pre-order/config.d.ts +1 -0
  49. package/dist/_internal/shared/listing-types/pre-order/config.js +2 -0
  50. package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -0
  51. package/dist/_internal/shared/listing-types/prize-draw/config.js +3 -0
  52. package/dist/_internal/shared/listing-types/standard/config.d.ts +1 -0
  53. package/dist/_internal/shared/listing-types/standard/config.js +2 -0
  54. package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -0
  55. package/dist/_internal/shared/listing-types/stickers/config.js +2 -0
  56. package/dist/client/observability/reportClientError.js +11 -1
  57. package/dist/client.d.ts +6 -0
  58. package/dist/client.js +10 -0
  59. package/dist/core/hooks/useSyncManager.js +11 -1
  60. package/dist/features/account/hooks/useNotifications.js +25 -5
  61. package/dist/features/auctions/components/AuctionBottomActions.js +10 -2
  62. package/dist/features/auctions/components/AuctionDetailPageView.js +12 -3
  63. package/dist/features/auctions/components/LiveBidPrice.js +2 -2
  64. package/dist/features/auctions/components/LiveMinIncrement.d.ts +1 -1
  65. package/dist/features/auctions/components/LiveMinIncrement.js +8 -0
  66. package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -3
  67. package/dist/features/auctions/components/PlaceBidFormClient.js +16 -1
  68. package/dist/features/auctions/hooks/useAuctions.js +12 -1
  69. package/dist/features/auctions/hooks/useRealtimeBids.d.ts +3 -0
  70. package/dist/features/auctions/hooks/useRealtimeBids.js +138 -36
  71. package/dist/features/blog/components/BlogIndexPageView.js +10 -1
  72. package/dist/features/blog/repository/blog.repository.d.ts +14 -0
  73. package/dist/features/blog/repository/blog.repository.js +23 -1
  74. package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +2 -2
  75. package/dist/features/catalogue/components/PublicCatalogueView.js +2 -2
  76. package/dist/features/categories/components/BundleCollage.js +10 -5
  77. package/dist/features/categories/components/BundleDetailView.js +7 -3
  78. package/dist/features/categories/components/CategoryDetailPageView.js +1 -1
  79. package/dist/features/categories/components/CategoryProductsListing.js +4 -1
  80. package/dist/features/categories/components/CategoryTree.js +1 -1
  81. package/dist/features/categories/repository/categories.repository.d.ts +34 -2
  82. package/dist/features/categories/repository/categories.repository.js +31 -9
  83. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -2
  84. package/dist/features/contact/email.js +36 -1
  85. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -2
  86. package/dist/features/homepage/components/FeaturedBundlesSection.js +4 -4
  87. package/dist/features/live/components/LiveItemDetailPageView.js +2 -2
  88. package/dist/features/orders/repository/orders.repository.d.ts +21 -1
  89. package/dist/features/orders/repository/orders.repository.js +26 -9
  90. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -3
  91. package/dist/features/pre-orders/components/PreOrderActionsClient.js +2 -2
  92. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -2
  93. package/dist/features/pre-orders/components/PreOrdersIndexListing.js +4 -1
  94. package/dist/features/products/actions/product-actions.d.ts +9 -0
  95. package/dist/features/products/actions/product-actions.js +11 -0
  96. package/dist/features/products/columns/productTableColumns.js +2 -2
  97. package/dist/features/products/components/AuctionsIndexListing.js +4 -1
  98. package/dist/features/products/components/BidHistory.js +2 -2
  99. package/dist/features/products/components/CompareOverlay.js +2 -2
  100. package/dist/features/products/components/GroupMemberPicker.js +5 -5
  101. package/dist/features/products/components/ListingBottomActions.js +12 -3
  102. package/dist/features/products/components/MakeOfferButton.js +7 -1
  103. package/dist/features/products/components/MarketplaceBundleCard.js +4 -4
  104. package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -2
  105. package/dist/features/products/components/PrizeDrawCollage.js +16 -5
  106. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
  107. package/dist/features/products/components/PrizeDrawsIndexListing.js +4 -1
  108. package/dist/features/products/components/PrizeRevealModal.js +2 -2
  109. package/dist/features/products/components/ProductDetailActions.js +10 -3
  110. package/dist/features/products/components/ProductFilters.js +2 -2
  111. package/dist/features/products/components/ProductGrid.js +5 -2
  112. package/dist/features/products/components/ProductsIndexListing.js +12 -1
  113. package/dist/features/products/components/ProductsIndexPageView.js +26 -0
  114. package/dist/features/products/components/ShowGroupSection.js +2 -2
  115. package/dist/features/products/components/SublistingCarouselSection.js +2 -2
  116. package/dist/features/products/constants/listing-tabs.d.ts +36 -12
  117. package/dist/features/products/constants/listing-tabs.js +38 -14
  118. package/dist/features/products/constants/sieve.d.ts +14 -0
  119. package/dist/features/products/constants/sieve.js +39 -0
  120. package/dist/features/products/repository/products.repository.d.ts +35 -0
  121. package/dist/features/products/repository/products.repository.js +116 -3
  122. package/dist/features/products/schemas/firestore.d.ts +37 -0
  123. package/dist/features/seller/actions/seller-actions.js +4 -13
  124. package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
  125. package/dist/features/seller/components/SellerProductShell.js +11 -2
  126. package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
  127. package/dist/features/stores/api/[storeSlug]/reviews/route.js +89 -30
  128. package/dist/features/stores/components/StoresIndexPageView.js +17 -1
  129. package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
  130. package/dist/features/tester/seed-data/authored/buying__bidding.js +5 -4
  131. package/dist/features/tester/seed-data/authored/buying__browsing-search.js +85 -85
  132. package/dist/features/tester/seed-data/authored/buying__image-tile-layout.js +2 -2
  133. package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
  134. package/dist/features/tester/seed-data/authored/buying__product-detail.js +53 -15
  135. package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
  136. package/dist/features/tester/seed-data/authored/content-discovery__search.js +6 -4
  137. package/dist/features/tester/seed-data/authored/design-ux__carousel-arrow-bounds.js +4 -4
  138. package/dist/features/tester/seed-data/authored/design-ux__homepage-carousels.js +2 -2
  139. package/dist/features/tester/seed-data/authored/design-ux__status-badge-legibility.js +4 -4
  140. package/dist/features/tester/seed-data/authored/design-ux__sticky-cta-bar.js +8 -8
  141. package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +8 -8
  142. package/dist/features/tester/seed-data/authored/seo__canonical-and-host.js +16 -0
  143. package/dist/features/tester/seed-data/authored/seo__og-images.js +17 -0
  144. package/dist/features/tester/seed-data/stores-tester-seed-data.js +41 -2
  145. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +36 -6
  146. package/dist/features/tester/utils/phases.js +78 -12
  147. package/dist/features/wishlist/components/WishlistPage.js +2 -2
  148. package/dist/index.d.ts +2 -1
  149. package/dist/index.js +4 -1
  150. package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
  151. package/dist/providers/db-firebase/rtdb-paths.js +7 -0
  152. package/dist/react/contexts/SessionContext.js +40 -1
  153. package/dist/react/hooks/useCanSeePrices.d.ts +37 -0
  154. package/dist/react/hooks/useCanSeePrices.js +40 -0
  155. package/dist/seed/addresses-seed-data.js +36 -2
  156. package/dist/seed/users-seed-data.js +10 -2
  157. package/dist/seo/json-ld.d.ts +21 -0
  158. package/dist/seo/json-ld.js +45 -0
  159. package/dist/server.d.ts +3 -0
  160. package/dist/server.js +12 -0
  161. package/dist/styles.css +41 -0
  162. package/dist/ui/components/FilePickerButton.d.ts +32 -0
  163. package/dist/ui/components/FilePickerButton.js +23 -0
  164. package/dist/ui/components/GatedPrice.d.ts +66 -0
  165. package/dist/ui/components/GatedPrice.js +80 -0
  166. package/dist/ui/components/GatedPrice.style.css +40 -0
  167. package/dist/ui/components/PriceDisplay.d.ts +12 -0
  168. package/dist/ui/components/PriceDisplay.js +17 -17
  169. package/dist/ui/components/index.style.css +1 -0
  170. package/dist/ui/index.d.ts +4 -0
  171. package/dist/ui/index.js +8 -0
  172. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { sortBy } from "@mohasinac/appkit";
3
3
  import { listPublicProducts } from "../../../_internal/server/features/products/list-public";
4
4
  import { AVAILABILITY_VALUES, PRODUCT_FIELDS } from "../../../constants/field-names";
5
5
  import { productRepository } from "../repository/products.repository";
6
+ import { GENERIC_PRODUCT_LISTING_TYPES } from "../constants/listing-tabs";
6
7
  import { ProductStatusValues } from "../schemas";
7
8
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility";
8
9
  /**
@@ -63,10 +64,20 @@ function toListResult(result, pageSize) {
63
64
  hasMore: result.hasMore,
64
65
  };
65
66
  }
67
+ /**
68
+ * Featured items for the homepage's generic "Featured Products" strip.
69
+ *
70
+ * Scoped to `GENERIC_PRODUCT_LISTING_TYPES` for the same reason `/products` is:
71
+ * the homepage already has dedicated Live Auctions, Pre-Orders and Prize Draws
72
+ * sections, so leaving this unscoped put the same item in two strips on one
73
+ * page — and an auction rendered as a generic product card shows Buy-Now chrome
74
+ * instead of bid affordances.
75
+ */
66
76
  export async function getFeaturedProducts(pageSize = 8) {
67
77
  return toListResult(await listPublicProducts({
68
78
  featured: true,
69
79
  availability: AVAILABILITY_VALUES.AVAILABLE,
80
+ listingTypes: GENERIC_PRODUCT_LISTING_TYPES,
70
81
  sorts: sortBy("createdAt", "DESC"),
71
82
  page: 1,
72
83
  pageSize,
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Button, Div, Row, Span } from "../../../ui";
2
+ import { Button, Div, GatedPrice, Row, Span } from "../../../ui";
3
3
  import { MediaImage } from "../../media/MediaImage";
4
4
  import { formatCurrency as defaultFormatCurrency } from "../../../utils/number.formatter";
5
5
  const __O = {
@@ -35,7 +35,7 @@ export function getProductTableColumns({ labels, onEdit, onDelete, currencyCode,
35
35
  header: labels.price,
36
36
  sortable: true,
37
37
  width: "10%",
38
- render: (product) => (_jsx(Span, { children: formatCurrency(product.price ?? 0, product.currency ?? currencyCode, locale) })),
38
+ render: (product) => (_jsx(Span, { children: _jsx(GatedPrice, { children: formatCurrency(product.price ?? 0, product.currency ?? currencyCode, locale) }) })),
39
39
  },
40
40
  {
41
41
  key: "stockQuantity",
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { withoutPriceSorts } from "../constants/sieve";
4
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
3
5
  import { useState, useCallback } from "react";
4
6
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
5
7
  import { useProducts } from "../hooks/useProducts";
@@ -30,6 +32,7 @@ const AUCTION_LISTING_TYPES = ["auction"];
30
32
  const FILTER_KEYS = [TABLE_KEYS.CATEGORY, TABLE_KEYS.BRAND, TABLE_KEYS.MIN_BID, TABLE_KEYS.MAX_BID, TABLE_KEYS.STORE_ID, TABLE_KEYS.DATE_FROM, TABLE_KEYS.DATE_TO];
31
33
  export function AuctionsIndexListing({ initialData, categorySlug, brandName }) {
32
34
  const table = useUrlTable({ defaults: { pageSize: "24", sort: DEFAULT_SORT } });
35
+ const { canSeePrices } = useCanSeePrices();
33
36
  const { showToast } = useToast();
34
37
  const { requireAuth, modalOpen, modalMessage, closeModal } = useAuthGate();
35
38
  const [searchInput, setSearchInput] = useState(table.get(TABLE_KEYS.QUERY) || "");
@@ -134,7 +137,7 @@ export function AuctionsIndexListing({ initialData, categorySlug, brandName }) {
134
137
  },
135
138
  },
136
139
  ] } } : {});
137
- return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: filterActiveCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search auctions...", onSearchChange: setSearchInput, onSearchCommit: commitSearch, onSearchKeyDown: handleSearchKeyDown, sortValue: table.get(TABLE_KEYS.SORT) || DEFAULT_SORT, sortOptions: AUCTION_PUBLIC_SORT_OPTIONS, onSortChange: (v) => { table.set(TABLE_KEYS.SORT, v); }, view: view, onViewChange: handleViewToggle, onResetAll: resetAll, hasActiveState: hasActiveState, bulkMode: selection.isSelecting, bulkSelectedCount: selection.selectedCount, bulkTotalCount: auctions.length, onBulkSelectAll: selection.toggleAll, onBulkClear: selection.clearSelection }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: AUCTION_LISTING_TYPES }) }), _jsx(BulkActionBar, { selectedCount: selection.selectedCount, onClearSelection: selection.clearSelection, actions: [
140
+ return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: filterActiveCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search auctions...", onSearchChange: setSearchInput, onSearchCommit: commitSearch, onSearchKeyDown: handleSearchKeyDown, sortValue: table.get(TABLE_KEYS.SORT) || DEFAULT_SORT, sortOptions: withoutPriceSorts(AUCTION_PUBLIC_SORT_OPTIONS, canSeePrices), onSortChange: (v) => { table.set(TABLE_KEYS.SORT, v); }, view: view, onViewChange: handleViewToggle, onResetAll: resetAll, hasActiveState: hasActiveState, bulkMode: selection.isSelecting, bulkSelectedCount: selection.selectedCount, bulkTotalCount: auctions.length, onBulkSelectAll: selection.toggleAll, onBulkClear: selection.clearSelection }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: AUCTION_LISTING_TYPES }) }), _jsx(BulkActionBar, { selectedCount: selection.selectedCount, onClearSelection: selection.clearSelection, actions: [
138
141
  {
139
142
  id: ACTION_ID.WATCH_AUCTION,
140
143
  label: "Add to Watchlist",
@@ -1,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { normalizeError } from "../../../errors/normalize";
3
3
  import React from "react";
4
- import { Div, Heading, Span, Stack, Text } from "../../../ui";
4
+ import { Div, GatedPrice, Heading, Span, Stack, Text } from "../../../ui";
5
5
  import { formatCurrency } from "../../../utils/number.formatter";
6
6
  function formatBidDate(raw) {
7
7
  if (!raw)
@@ -40,5 +40,5 @@ export function BidHistory({ isLoading = false, isEmpty = false, renderList, ren
40
40
  }
41
41
  if (renderList)
42
42
  return _jsx(Div, { className: className, children: renderList() });
43
- return (_jsxs(Div, { className: className, children: [labels.title && (_jsx(Heading, { level: 3, className: "mb-3 tracking-wide", color: "muted", size: "sm", weight: "semibold", transform: "uppercase", children: labels.title })), _jsx(Stack, { gap: "sm", children: bids.map((bid, i) => renderBid ? (_jsx(React.Fragment, { children: renderBid(bid, i) }, bid.id)) : (_jsxs(Div, { layout: "flex", align: "center", justify: "between", rounded: "lg", padding: "sm", textSize: "sm", className: "border border-[var(--appkit-color-border-subtle)] bg-[var(--appkit-color-surface)]", "data-section": "bidhistory-div-422", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Span, { weight: "bold", className: "text-primary-600 dark:text-primary-400", children: currency ? formatCurrency(bid.amount, currency) : bid.amount.toLocaleString() }), _jsx(Span, { size: "xs", color: "muted", children: bid.bidderName || maskBidderId(bid.bidderId) })] }), _jsx(Span, { size: "xs", color: "muted", children: formatBidDate(bid.placedAt) })] }, bid.id))) })] }));
43
+ return (_jsxs(Div, { className: className, children: [labels.title && (_jsx(Heading, { level: 3, className: "mb-3 tracking-wide", color: "muted", size: "sm", weight: "semibold", transform: "uppercase", children: labels.title })), _jsx(Stack, { gap: "sm", children: bids.map((bid, i) => renderBid ? (_jsx(React.Fragment, { children: renderBid(bid, i) }, bid.id)) : (_jsxs(Div, { layout: "flex", align: "center", justify: "between", rounded: "lg", padding: "sm", textSize: "sm", className: "border border-[var(--appkit-color-border-subtle)] bg-[var(--appkit-color-surface)]", "data-section": "bidhistory-div-422", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Span, { weight: "bold", className: "text-primary-600 dark:text-primary-400", children: _jsx(GatedPrice, { label: "Sign in to see bids", children: currency ? formatCurrency(bid.amount, currency) : bid.amount.toLocaleString() }) }), _jsx(Span, { size: "xs", color: "muted", children: bid.bidderName || maskBidderId(bid.bidderId) })] }), _jsx(Span, { size: "xs", color: "muted", children: formatBidDate(bid.placedAt) })] }, bid.id))) })] }));
44
44
  }
@@ -12,7 +12,7 @@ const REL_NOOPENER = "noopener noreferrer";
12
12
  const CLS_REMOVE_BTN = "shrink-0 text-zinc-400 hover:text-error hover:bg-surface-hover";
13
13
  import Link from "next/link";
14
14
  import { X } from "lucide-react";
15
- import { Button, Div, Heading, IconButton, Row, Span, Stack, Text } from "../../../ui";
15
+ import { Button, Div, GatedPrice, Heading, IconButton, Row, Span, Stack, Text } from "../../../ui";
16
16
  import { MediaImage } from "../../media/MediaImage";
17
17
  import { useSwipe } from "../../../react/hooks/useSwipe";
18
18
  import { formatCurrency } from "../../../utils/number.formatter";
@@ -67,7 +67,7 @@ function customFieldValue(field) {
67
67
  function CompareColumn({ item, productType, labels, onRemove, onClose, customFieldKeys }) {
68
68
  const href = detailHref(item, productType);
69
69
  const img = item.mainImage ?? item.images?.[0];
70
- return (_jsxs(Stack, { gap: "md", className: COLUMN_CARD_CLASS, children: [_jsxs(Row, { justify: "between", align: "start", gap: "sm", children: [_jsx(Link, { href: href, target: "_blank", rel: REL_NOOPENER, className: "text-[length:var(--appkit-text-sm)] font-semibold text-[var(--appkit-color-text)] hover:underline line-clamp-2", children: item.title ?? item.id }), onRemove && (_jsx(IconButton, { type: "button", onClick: () => onRemove(item.id), "aria-label": `${labels.remove} ${item.title ?? item.id}`, variant: "ghost", rounded: "full", className: CLS_REMOVE_BTN, children: _jsx(X, { className: "h-4 w-4" }) }))] }), _jsx(FieldRow, { label: labels.field.image, children: _jsx(Link, { href: href, target: "_blank", rel: REL_NOOPENER, children: _jsx(Div, { className: `relative aspect-square ${__O.hidden}`, rounded: "lg", surface: "subtle", children: img ? (_jsx(MediaImage, { src: img, alt: item.title ?? item.id, size: "card" })) : null }) }) }), _jsx(FieldRow, { label: labels.field.price, children: _jsx(Text, { size: "base", weight: "semibold", color: "primary", children: priceLabel(item) }) }), _jsx(FieldRow, { label: labels.field.condition, children: _jsx(Text, { children: item.condition ? _jsx(Span, { className: CHIP_CLASS, children: item.condition }) : "—" }) }), _jsx(FieldRow, { label: labels.field.brand, children: _jsx(Text, { children: item.brand ? _jsx(Span, { className: CHIP_CLASS, children: item.brand }) : "—" }) }), _jsx(FieldRow, { label: labels.field.category, children: _jsx(Text, { children: (Array.isArray(item.categoryNames) ? item.categoryNames[0] : item.categoryName) || (Array.isArray(item.categorySlugs) ? item.categorySlugs[0] : item.category) ? (_jsx(Span, { className: CHIP_CLASS, children: (Array.isArray(item.categoryNames) ? item.categoryNames[0] : item.categoryName) ?? (Array.isArray(item.categorySlugs) ? item.categorySlugs[0] : item.category) })) : ("—") }) }), _jsx(FieldRow, { label: labels.field.store, children: _jsx(Text, { size: "sm", color: "muted", children: item.storeName ?? "—" }) }), customFieldKeys.map((key) => (_jsx(FieldRow, { label: key, children: _jsx(Text, { size: "sm", children: customFieldValue(item.customFields?.find((f) => f.key === key)) }) }, key))), _jsx(Button, { asChild: true, variant: "primary", size: "sm", className: "mt-auto", children: _jsx(Link, { href: href, target: "_blank", rel: REL_NOOPENER, onClick: onClose, children: labels.view }) })] }));
70
+ return (_jsxs(Stack, { gap: "md", className: COLUMN_CARD_CLASS, children: [_jsxs(Row, { justify: "between", align: "start", gap: "sm", children: [_jsx(Link, { href: href, target: "_blank", rel: REL_NOOPENER, className: "text-[length:var(--appkit-text-sm)] font-semibold text-[var(--appkit-color-text)] hover:underline line-clamp-2", children: item.title ?? item.id }), onRemove && (_jsx(IconButton, { type: "button", onClick: () => onRemove(item.id), "aria-label": `${labels.remove} ${item.title ?? item.id}`, variant: "ghost", rounded: "full", className: CLS_REMOVE_BTN, children: _jsx(X, { className: "h-4 w-4" }) }))] }), _jsx(FieldRow, { label: labels.field.image, children: _jsx(Link, { href: href, target: "_blank", rel: REL_NOOPENER, children: _jsx(Div, { className: `relative aspect-square ${__O.hidden}`, rounded: "lg", surface: "subtle", children: img ? (_jsx(MediaImage, { src: img, alt: item.title ?? item.id, size: "card" })) : null }) }) }), _jsx(FieldRow, { label: labels.field.price, children: _jsx(Text, { size: "base", weight: "semibold", color: "primary", children: _jsx(GatedPrice, { children: priceLabel(item) }) }) }), _jsx(FieldRow, { label: labels.field.condition, children: _jsx(Text, { children: item.condition ? _jsx(Span, { className: CHIP_CLASS, children: item.condition }) : "—" }) }), _jsx(FieldRow, { label: labels.field.brand, children: _jsx(Text, { children: item.brand ? _jsx(Span, { className: CHIP_CLASS, children: item.brand }) : "—" }) }), _jsx(FieldRow, { label: labels.field.category, children: _jsx(Text, { children: (Array.isArray(item.categoryNames) ? item.categoryNames[0] : item.categoryName) || (Array.isArray(item.categorySlugs) ? item.categorySlugs[0] : item.category) ? (_jsx(Span, { className: CHIP_CLASS, children: (Array.isArray(item.categoryNames) ? item.categoryNames[0] : item.categoryName) ?? (Array.isArray(item.categorySlugs) ? item.categorySlugs[0] : item.category) })) : ("—") }) }), _jsx(FieldRow, { label: labels.field.store, children: _jsx(Text, { size: "sm", color: "muted", children: item.storeName ?? "—" }) }), customFieldKeys.map((key) => (_jsx(FieldRow, { label: key, children: _jsx(Text, { size: "sm", children: customFieldValue(item.customFields?.find((f) => f.key === key)) }) }, key))), _jsx(Button, { asChild: true, variant: "primary", size: "sm", className: "mt-auto", children: _jsx(Link, { href: href, target: "_blank", rel: REL_NOOPENER, onClick: onClose, children: labels.view }) })] }));
71
71
  }
72
72
  function useFetchedProducts(productIds, passedItems, isOpen) {
73
73
  const [fetched, setFetched] = useState([]);
@@ -18,7 +18,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
18
18
  * consumer pages untouched.
19
19
  */
20
20
  import { useMemo, useState } from "react";
21
- import { Alert, Button, Div, Row, Span, Stack, Table, Tbody, Td, Text, Th, Thead, Tr, } from "../../../ui";
21
+ import { Alert, Button, Div, GatedPrice, PricesOnly, Row, Span, Stack, Table, Tbody, Td, Text, Th, Thead, Tr, } from "../../../ui";
22
22
  import { QuantityStepper } from "../../../ui/components/QuantityStepper";
23
23
  import { MediaImage } from "../../media/MediaImage";
24
24
  import { formatCurrency } from "../../../utils/number.formatter";
@@ -101,8 +101,8 @@ export function GroupMemberPicker({ groupId, groupSource, members, onAdded, onAu
101
101
  };
102
102
  return (_jsxs(Stack, { gap: "sm", children: [isCrossStore && (_jsx(Alert, { variant: "info", children: "These items are sold by different sellers, so they can't be combined into one cart line. Open an item to buy it on its own." })), _jsx(Div, { className: __O.xAuto, children: _jsxs(Table, { className: "text-left min-w-[440px]", children: [_jsx(Thead, { children: _jsxs(Tr, { border: "default", children: [_jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Image" }), _jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Name" }), _jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Price" }), !isCrossStore && (_jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Qty" })), _jsx(Th, { paddingY: "sm", paddingSide: "pb-sm", color: "muted", size: "xs", weight: "semibold" })] }) }), _jsx(Tbody, { children: members.map((m) => {
103
103
  const blocked = blockedReason(m);
104
- return (_jsxs(Tr, { className: "last:border-0", border: "subtle", children: [_jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Div, { className: `w-10 h-10 ${__O.hidden}`, rounded: "full", border: "default", children: _jsx(MediaImage, { src: memberImage(m), alt: m.title, size: "thumbnail" }) }) }), _jsxs(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: [_jsx(Text, { className: "line-clamp-2", color: "primary", size: "sm", weight: "medium", children: m.title }), m.isGroupParent && (_jsx(Span, { weight: "semibold", className: "text-[10px] text-[var(--appkit-color-primary)]", children: "Parent" }))] }), _jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Text, { size: "sm", color: "muted", children: formatCurrency(m.price, m.currency ?? currency) }) }), !isCrossStore && (_jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: blocked ? (_jsx(Span, { size: "xs", color: "muted", surface: "muted", rounded: "default", padding: "pill-2xs", children: blocked })) : (_jsx(QuantityStepper, { value: qty[m.id] ?? 0, onChange: (next) => setQty((prev) => ({ ...prev, [m.id]: next })), min: 0, max: m.availableQuantity, ariaLabel: `Quantity for ${m.title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel })) })), _jsx(Td, { padding: "xs-tall", children: _jsx("a", { href: memberHref(m), className: "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline", children: "View \u2192" }) })] }, m.id));
105
- }) })] }) }), !isCrossStore && (_jsxs(_Fragment, { children: [error && _jsx(Alert, { variant: "error", children: error }), _jsxs(Row, { justify: "between", align: "center", gap: "sm", className: "min-w-0", children: [_jsx(Text, { size: "sm", color: "muted", children: selectedUnits > 0
106
- ? `${selectedUnits} item${selectedUnits === 1 ? "" : "s"} · ${formatCurrency(selectedTotal, currency)}`
107
- : "Nothing selected yet" }), _jsx(Button, { action: ACTIONS.CART["add-group-to-cart"], disabled: selectedUnits === 0, isLoading: isSaving, onClick: handleAdd })] })] }))] }));
104
+ return (_jsxs(Tr, { className: "last:border-0", border: "subtle", children: [_jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Div, { className: `w-10 h-10 ${__O.hidden}`, rounded: "full", border: "default", children: _jsx(MediaImage, { src: memberImage(m), alt: m.title, size: "thumbnail" }) }) }), _jsxs(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: [_jsx(Text, { className: "line-clamp-2", color: "primary", size: "sm", weight: "medium", children: m.title }), m.isGroupParent && (_jsx(Span, { weight: "semibold", className: "text-[10px] text-[var(--appkit-color-primary)]", children: "Parent" }))] }), _jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Text, { size: "sm", color: "muted", children: _jsx(GatedPrice, { children: formatCurrency(m.price, m.currency ?? currency) }) }) }), !isCrossStore && (_jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: blocked ? (_jsx(Span, { size: "xs", color: "muted", surface: "muted", rounded: "default", padding: "pill-2xs", children: blocked })) : (_jsx(QuantityStepper, { value: qty[m.id] ?? 0, onChange: (next) => setQty((prev) => ({ ...prev, [m.id]: next })), min: 0, max: m.availableQuantity, ariaLabel: `Quantity for ${m.title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel })) })), _jsx(Td, { padding: "xs-tall", children: _jsx("a", { href: memberHref(m), className: "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline", children: "View \u2192" }) })] }, m.id));
105
+ }) })] }) }), !isCrossStore && (_jsxs(_Fragment, { children: [error && _jsx(Alert, { variant: "error", children: error }), _jsxs(Row, { justify: "between", align: "center", gap: "sm", className: "min-w-0", children: [_jsxs(Text, { size: "sm", color: "muted", children: [selectedUnits > 0
106
+ ? `${selectedUnits} item${selectedUnits === 1 ? "" : "s"}`
107
+ : "Nothing selected yet", selectedUnits > 0 && (_jsx(PricesOnly, { children: ` · ${formatCurrency(selectedTotal, currency)}` }))] }), _jsx(Button, { action: ACTIONS.CART["add-group-to-cart"], disabled: selectedUnits === 0, isLoading: isSaving, onClick: handleAdd })] })] }))] }));
108
108
  }
@@ -1,12 +1,21 @@
1
1
  "use client";
2
2
  import { useBottomActions } from "../../layout/hooks/useBottomActions";
3
3
  import { formatCurrency } from "../../../utils/number.formatter";
4
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
4
5
  import { ACTION_META, MOBILE_PRIMARY_ACTIONS } from "../constants/action-defs";
5
6
  export function ListingBottomActions({ listingType, anchorId, price, currency, unavailable = false, infoSuffix, }) {
6
7
  const actionIds = MOBILE_PRIMARY_ACTIONS[listingType] ?? [];
7
- const infoLabel = price !== null
8
- ? `${formatCurrency(price, currency)}${infoSuffix ? ` ${infoSuffix}` : ""}`
9
- : undefined;
8
+ const { canSeePrices } = useCanSeePrices();
9
+ // `infoLabel` is a plain string on the bottom bar, not a slot, so it cannot
10
+ // be wrapped in <GatedPrice> — the gate has to happen while the string is
11
+ // built. Note this label is ALSO the re-publish key for `useBottomActions`
12
+ // (see CLAUDE.md's bottom-edge notes), so it has to change when the viewer's
13
+ // access changes, which it does: the two branches never produce equal text.
14
+ const infoLabel = price === null
15
+ ? undefined
16
+ : canSeePrices
17
+ ? `${formatCurrency(price, currency)}${infoSuffix ? ` ${infoSuffix}` : ""}`
18
+ : "Sign in to see price";
10
19
  useBottomActions(unavailable
11
20
  ? {}
12
21
  : {
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState, useTransition } from "react";
4
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
4
5
  import { Button, Div, LoginRequiredModal, Modal, Span, Stack, Text, } from "../../../ui";
5
6
  import { Form } from "../../../ui/components/Form";
6
7
  import { FieldInput } from "../../../ui/forms/FieldInput";
@@ -30,6 +31,7 @@ function isActiveOfferError(msg) {
30
31
  export function MakeOfferButton({ productId, listedPrice, currency, bounds, onMakeOffer, className = "", }) {
31
32
  const [state, setState] = useState("idle");
32
33
  const [showLoginModal, setShowLoginModal] = useState(false);
34
+ const { canSeePrices } = useCanSeePrices();
33
35
  const [isPending, startTransition] = useTransition();
34
36
  const fmt = (n) => (currency ? formatCurrency(n, currency) : `₹${n.toLocaleString()}`);
35
37
  // The SAME bounds the server enforces — passed in, never recomputed, so the
@@ -84,7 +86,11 @@ export function MakeOfferButton({ productId, listedPrice, currency, bounds, onMa
84
86
  if (state === "pending") {
85
87
  return (_jsxs(Div, { className: `${CLS_PENDING_BOX} ${className}`, children: [_jsx(Span, { size: "lg", children: "\u23F3" }), _jsx(Text, { className: CLS_PENDING_TITLE, children: bounds.isBuyRequest ? "Request Pending" : "Offer Pending" }), _jsxs(Text, { className: CLS_PENDING_BODY, children: ["You already have ", bounds.isBuyRequest ? "a request" : "an offer", " on this item. Check My Offers for updates."] })] }));
86
88
  }
87
- return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to make an offer. Please log in or create an account to continue." }), _jsx(Button, { variant: "ghost", size: "md", border: "strong", className: `w-full ${className}`, onClick: () => setState("open"), children: bounds.isBuyRequest ? "Request to Buy" : "Make Offer" }), _jsx(Modal, { isOpen: state === "open", onClose: () => setState("idle"), size: "md", title: bounds.isBuyRequest ? "Request to buy" : "Make an offer", children: _jsx(Form, { schema: schema, onSubmit: (e) => e.preventDefault(), children: ({ setFieldError, clearErrors, markSubmitAttempted }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Text, { size: "xs", color: "muted", children: bounds.isBuyRequest
89
+ return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to make an offer. Please log in or create an account to continue." }), _jsx(Button, { variant: "ghost", size: "md", border: "strong", className: `w-full ${className}`,
90
+ // The modal's first line is "Listed at ₹X · Minimum offer: ₹Y", so
91
+ // opening it for a signed-out visitor both discloses the price and
92
+ // walks them into a form that can only fail at submit.
93
+ onClick: () => (canSeePrices ? setState("open") : setShowLoginModal(true)), children: bounds.isBuyRequest ? "Request to Buy" : "Make Offer" }), _jsx(Modal, { isOpen: state === "open", onClose: () => setState("idle"), size: "md", title: bounds.isBuyRequest ? "Request to buy" : "Make an offer", children: _jsx(Form, { schema: schema, onSubmit: (e) => e.preventDefault(), children: ({ setFieldError, clearErrors, markSubmitAttempted }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Text, { size: "xs", color: "muted", children: bounds.isBuyRequest
88
94
  ? `Listed at ${fmt(listedPrice)} · this seller is not taking lower offers`
89
95
  : `Listed at ${fmt(listedPrice)} · Minimum offer: ${fmt(bounds.min)}` }), _jsx(FieldInput, { name: "offerAmount", type: "number", label: bounds.isBuyRequest ? "Amount" : "Your offer amount", required: true, value: offerAmount, onChange: (value) => {
90
96
  setOfferAmount(value);
@@ -10,7 +10,7 @@ const CLS_BUNDLE_PILL = "inline-flex items-center rounded-full bg-violet-600 px-
10
10
  const CLS_STOCK_OK = "bg-success-solid text-success-on-solid";
11
11
  // audit-content-alignment-ok: button label text, not page content — buttons are universally center-labeled
12
12
  const CLS_VIEW_BTN = "mt-2 w-full cursor-pointer rounded-md bg-violet-600 py-[var(--appkit-space-1-5)] text-center text-[length:var(--appkit-text-xs)] font-semibold text-white transition-colors hover:bg-violet-700 active:scale-[0.98]";
13
- import { BaseListingCard, Div, Row, Span, Stack, Text, TextLink } from "../../../ui";
13
+ import { BaseListingCard, Div, GatedPrice, Row, Span, Stack, Text, TextLink } from "../../../ui";
14
14
  import { MediaImage } from "../../media/MediaImage";
15
15
  import { computeBundleDiscount } from "../../../_internal/shared/features/categories/bundle-pricing";
16
16
  import { BUNDLE_COPY } from "../../../_internal/shared/features/categories/bundle-copy";
@@ -54,8 +54,8 @@ export function MarketplaceBundleCard({ bundle, className = "", variant = "grid"
54
54
  onSelect(bundle.id, !isSelected);
55
55
  }, className: selectable || isSelected
56
56
  ? "opacity-100"
57
- : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" }))] }), _jsxs(BaseListingCard.Info, { variant: variant, children: [_jsx(TextLink, { href: String(detailHref), children: _jsx(Text, { size: "sm", weight: "medium", color: "primary", truncate: 2, children: bundle.name }) }), _jsxs(Row, { justify: "between", className: "mt-1", gap: "sm", wrap: true, children: [_jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: price
58
- ? formatCurrency(price, getDefaultCurrency())
59
- : "—" }), discount && (_jsx(Text, { size: "xs", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, getDefaultCurrency()) }))] }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: mergedLabels.itemsLabel(memberCount) })] }), _jsx(Div, { className: CLS_VIEW_BTN, role: "button", tabIndex: 0, onClick: handleNavigate, onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ")
57
+ : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" }))] }), _jsxs(BaseListingCard.Info, { variant: variant, children: [_jsx(TextLink, { href: String(detailHref), children: _jsx(Text, { size: "sm", weight: "medium", color: "primary", truncate: 2, children: bundle.name }) }), _jsxs(Row, { justify: "between", className: "mt-1", gap: "sm", wrap: true, children: [_jsx(Row, { gap: "xs", align: "center", children: _jsx(GatedPrice, { label: "Sign in to see the bundle price", children: _jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: price
58
+ ? formatCurrency(price, getDefaultCurrency())
59
+ : "—" }), discount && (_jsx(Text, { size: "xs", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, getDefaultCurrency()) }))] }) }) }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: mergedLabels.itemsLabel(memberCount) })] }), _jsx(Div, { className: CLS_VIEW_BTN, role: "button", tabIndex: 0, onClick: handleNavigate, onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ")
60
60
  handleNavigate(); }, children: "View Bundle" })] })] }));
61
61
  }
@@ -8,7 +8,7 @@ import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
8
8
  import { formatCurrency } from "../../../utils";
9
9
  import { getDefaultCurrency } from "../../../core/baseline-resolver";
10
10
  const CLS_PRIZE_PILL = "inline-flex items-center rounded-full bg-fuchsia-600 px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-white";
11
- import { BaseListingCard, Button, Div, Row, Span, Stack, Text, TextLink } from "../../../ui";
11
+ import { BaseListingCard, Button, Div, GatedPrice, Row, Span, Stack, Text, TextLink } from "../../../ui";
12
12
  import { MediaImage } from "../../media/MediaImage";
13
13
  import { CardStoreLine } from "./CardStoreLine";
14
14
  const DEFAULT_LABELS = {
@@ -99,5 +99,5 @@ export function MarketplacePrizeDrawCard({ product, className = "", variant = "g
99
99
  onSelect(product.id, !isSelected);
100
100
  }, className: selectable || isSelected
101
101
  ? "opacity-100"
102
- : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" }))] }), _jsxs(BaseListingCard.Info, { variant: variant, children: [_jsx(TextLink, { href: String(detailHref), children: _jsx(Text, { size: "sm", weight: "medium", color: "primary", truncate: 2, children: product.title }) }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Row, { justify: "between", className: "mt-1", gap: "sm", children: [_jsxs(Text, { size: "sm", weight: "semibold", color: "primary", children: [formatCurrency(pricePerEntry, getDefaultCurrency()), " ", _jsx(Span, { size: "xs", weight: "normal", className: "text-[var(--appkit-color-text-muted)]", children: mergedLabels.pricePerEntryLabel })] }), max > 0 ? (_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: mergedLabels.entriesRemainingLabel(remaining, max) })) : null] }), countdown ? (_jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: [status === "pending" ? "Opens in" : "Closes in", " ", countdown] })) : null, _jsx(Button, { type: "button", variant: "primary", size: "sm", textSize: "xs", className: "mt-2 w-full", onClick: handleNavigate, disabled: status === "closed" || remaining === 0, children: mergedLabels.enterDraw })] })] }));
102
+ : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" }))] }), _jsxs(BaseListingCard.Info, { variant: variant, children: [_jsx(TextLink, { href: String(detailHref), children: _jsx(Text, { size: "sm", weight: "medium", color: "primary", truncate: 2, children: product.title }) }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Row, { justify: "between", className: "mt-1", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: _jsxs(GatedPrice, { label: "Sign in to see the entry price", children: [formatCurrency(pricePerEntry, getDefaultCurrency()), " ", _jsx(Span, { size: "xs", weight: "normal", className: "text-[var(--appkit-color-text-muted)]", children: mergedLabels.pricePerEntryLabel })] }) }), max > 0 ? (_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: mergedLabels.entriesRemainingLabel(remaining, max) })) : null] }), countdown ? (_jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: [status === "pending" ? "Opens in" : "Closes in", " ", countdown] })) : null, _jsx(Button, { type: "button", variant: "primary", size: "sm", textSize: "xs", className: "mt-2 w-full", onClick: handleNavigate, disabled: status === "closed" || remaining === 0, children: mergedLabels.enterDraw })] })] }));
103
103
  }
@@ -16,7 +16,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
16
16
  */
17
17
  import { Row } from "@mohasinac/appkit/client";
18
18
  import { useState } from "react";
19
- import { Button, Div, Grid, Scrim, Text } from "../../../ui";
19
+ import { Button, Div, Grid, PricesOnly, Scrim, Text } from "../../../ui";
20
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
20
21
  import { MediaImage } from "../../media/MediaImage";
21
22
  import { ImageLightbox } from "../../../ui/components/ImageLightbox";
22
23
  const __P = {
@@ -34,23 +35,29 @@ function makePrizeItemClickHandler(it, idx, onItemClick, setLightboxIndex) {
34
35
  };
35
36
  }
36
37
  /** Build the lightbox images array from sorted prize items. */
37
- function toGalleryImages(items) {
38
+ function toGalleryImages(items, canSeePrices) {
38
39
  return items.map((it) => ({
39
40
  src: it.images?.[0] ?? "",
40
41
  alt: it.title || `Prize #${it.itemNumber}`,
41
42
  badge: `#${it.itemNumber}`,
42
43
  caption: it.title || `Prize #${it.itemNumber}`,
43
- sub: it.estimatedValue != null
44
+ // `sub` is a plain caption string, so the gate happens here — the tile's
45
+ // own estimate is gated in the JSX below and this would otherwise print it
46
+ // again one click away.
47
+ sub: canSeePrices && it.estimatedValue != null
44
48
  ? `est. ₹${it.estimatedValue.toLocaleString("en-IN")}`
45
49
  : undefined,
46
50
  }));
47
51
  }
48
52
  export function PrizeDrawCollage({ items, highlightItemNumber, onItemClick, wonLabel = "Won", hideWonState = false, }) {
49
53
  const [lightboxIndex, setLightboxIndex] = useState(null);
54
+ // Above the empty-state early return — a hook after a conditional return is a
55
+ // render-order violation, not a style preference.
56
+ const { canSeePrices } = useCanSeePrices();
50
57
  if (!items.length) {
51
58
  return (_jsx(Div, { className: `border border-dashed border-[var(--appkit-color-border)] ${__P.p6} text-left`, rounded: "default", children: _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "No prizes configured yet." }) }));
52
59
  }
53
- const galleryImages = toGalleryImages(items);
60
+ const galleryImages = toGalleryImages(items, canSeePrices);
54
61
  return (_jsxs(_Fragment, { children: [_jsx(Grid, { cols: "categoryCards", gap: "3", children: items.map((it, idx) => {
55
62
  const cover = it.images?.[0];
56
63
  const isHighlight = highlightItemNumber === it.itemNumber;
@@ -68,7 +75,11 @@ export function PrizeDrawCollage({ items, highlightItemNumber, onItemClick, wonL
68
75
  isHighlight
69
76
  ? "ring-2 ring-offset-2 ring-[var(--appkit-color-primary)] scale-[1.02]"
70
77
  : "hover:scale-[1.01]",
71
- ].join(" "), "aria-label": `View ${it.title || `prize #${it.itemNumber}`} in lightbox`, children: [_jsxs(Div, { className: "relative aspect-square w-full", children: [cover ? (_jsx(MediaImage, { src: cover, alt: it.title || `Prize #${it.itemNumber}`, size: "thumbnail" })) : (_jsx(Row, { className: "absolute inset-0 bg-[var(--appkit-color-surface-muted)]", align: "center", justify: "center", children: _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: "No image" }) })), _jsxs(Div, { textWeight: "semibold", textSize: "xs", surface: "overlay-xl", padding: "chip-2xs", className: "absolute left-2 top-2 text-white", rounded: "default", children: ["#", it.itemNumber] }), it.isWon && !hideWonState ? (_jsxs(_Fragment, { children: [_jsx(Scrim, { direction: "diagonal", intensity: "medium", multiply: true, className: "absolute inset-0" }), _jsx(Row, { className: "absolute inset-0", align: "center", justify: "center", children: _jsx(Text, { className: CLS_WON_STAMP, children: wonLabel }) })] })) : null] }), _jsxs(Div, { padding: "xs", children: [_jsx(Text, { className: "line-clamp-2", size: "sm", weight: "medium", children: it.title || `Prize #${it.itemNumber}` }), it.estimatedValue != null ? (_jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: ["est. \u20B9", it.estimatedValue.toLocaleString("en-IN")] })) : null] })] }, `collage-${it.itemNumber}`));
78
+ ].join(" "), "aria-label": `View ${it.title || `prize #${it.itemNumber}`} in lightbox`, children: [_jsxs(Div, { className: "relative aspect-square w-full", children: [cover ? (_jsx(MediaImage, { src: cover, alt: it.title || `Prize #${it.itemNumber}`, size: "thumbnail" })) : (_jsx(Row, { className: "absolute inset-0 bg-[var(--appkit-color-surface-muted)]", align: "center", justify: "center", children: _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: "No image" }) })), _jsxs(Div, { textWeight: "semibold", textSize: "xs", surface: "overlay-xl", padding: "chip-2xs", className: "absolute left-2 top-2 text-white", rounded: "default", children: ["#", it.itemNumber] }), it.isWon && !hideWonState ? (_jsxs(_Fragment, { children: [_jsx(Scrim, { direction: "diagonal", intensity: "medium", multiply: true, className: "absolute inset-0" }), _jsx(Row, { className: "absolute inset-0", align: "center", justify: "center", children: _jsx(Text, { className: CLS_WON_STAMP, children: wonLabel }) })] })) : null] }), _jsxs(Div, { padding: "xs", children: [_jsx(Text, { className: "line-clamp-2", size: "sm", weight: "medium", children: it.title || `Prize #${it.itemNumber}` }), it.estimatedValue != null ? (
79
+ // The prize's estimated value is a money amount like any
80
+ // other — hidden rather than prompted, because the entry
81
+ // price beside it already carries the sign-in gate.
82
+ _jsx(PricesOnly, { children: _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", children: ["est. \u20B9", it.estimatedValue.toLocaleString("en-IN")] }) })) : null] })] }, `collage-${it.itemNumber}`));
72
83
  }) }), _jsx(ImageLightbox, { images: galleryImages, activeIndex: lightboxIndex, onClose: () => setLightboxIndex(null), onNavigate: (i) => setLightboxIndex(i), showThumbnails: true })] }));
73
84
  }
74
85
  export default PrizeDrawCollage;
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useState } from "react";
4
4
  import { useRouter } from "next/navigation";
5
- import { Anchor, Button, Stack, Text, LoginRequiredModal } from "../../../ui";
5
+ import { Anchor, Button, GatedPrice, Stack, Text, LoginRequiredModal } from "../../../ui";
6
6
  import { ROUTES } from "../../../next";
7
7
  import { formatCurrency } from "../../../utils/number.formatter";
8
8
  import { NonRefundableConsentModal } from "./NonRefundableConsentModal";
@@ -31,5 +31,5 @@ export function PrizeDrawEntryActions({ productId, productSlug, title, pricePerE
31
31
  const slug = productSlug ?? productId;
32
32
  router.push(`${String(ROUTES.USER.CHECKOUT)}?directItem=${encodeURIComponent(slug)}&type=prize-draw`);
33
33
  }, [productSlug, productId, router]);
34
- return (_jsxs(Stack, { gap: "md", children: [_jsxs(Text, { size: "2xl", weight: "bold", color: "primary", children: [formatCurrency(pricePerEntry, currency), _jsx(Text, { as: "span", className: "ml-1 text-[var(--appkit-color-text-muted)]", size: "xs", weight: "normal", children: "per entry" })] }), _jsx(Button, { action: closed ? undefined : ACTIONS.PRIZE_DRAW["buy-now"], variant: "primary", size: "md", className: "w-full", disabled: closed || isAuthResolving, onClick: handleEnter, children: closed ? "Draw closed" : isAuthResolving ? "Checking…" : undefined }), prizeGithubFileUrl ? (_jsx(Anchor, { href: prizeGithubFileUrl, tone: "none", underline: "hover", size: "xs", weight: "medium", className: "text-left text-primary-600 underline-offset-4 dark:text-primary-400", children: "View RNG source code on GitHub \u2192" })) : null, _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", align: "start", children: "Winners chosen by Node.js crypto.randomInt \u2014 fully auditable. Entries are locked once paid; refunds only if the prize pool is exhausted." }), _jsx(NonRefundableConsentModal, { open: consentOpen, listingType: "prize-draw", itemTitle: title, priceLabel: formatCurrency(pricePerEntry, currency), onCancel: () => setConsentOpen(false), onConfirm: handleConfirm }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
34
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsxs(GatedPrice, { label: "Sign in to see the entry price", children: [formatCurrency(pricePerEntry, currency), _jsx(Text, { as: "span", className: "ml-1 text-[var(--appkit-color-text-muted)]", size: "xs", weight: "normal", children: "per entry" })] }) }), _jsx(Button, { action: closed ? undefined : ACTIONS.PRIZE_DRAW["buy-now"], variant: "primary", size: "md", className: "w-full", disabled: closed || isAuthResolving, onClick: handleEnter, children: closed ? "Draw closed" : isAuthResolving ? "Checking…" : undefined }), prizeGithubFileUrl ? (_jsx(Anchor, { href: prizeGithubFileUrl, tone: "none", underline: "hover", size: "xs", weight: "medium", className: "text-left text-primary-600 underline-offset-4 dark:text-primary-400", children: "View RNG source code on GitHub \u2192" })) : null, _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", align: "start", children: "Winners chosen by Node.js crypto.randomInt \u2014 fully auditable. Entries are locked once paid; refunds only if the prize pool is exhausted." }), _jsx(NonRefundableConsentModal, { open: consentOpen, listingType: "prize-draw", itemTitle: title, priceLabel: formatCurrency(pricePerEntry, currency), onCancel: () => setConsentOpen(false), onConfirm: handleConfirm }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
35
35
  }
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { withoutPriceSorts } from "../constants/sieve";
4
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
3
5
  import { useState, useCallback } from "react";
4
6
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
5
7
  import { usePendingTable } from "../../../react/hooks/usePendingTable";
@@ -28,6 +30,7 @@ const FILTER_KEYS = [
28
30
  ];
29
31
  export function PrizeDrawsIndexListing({ initialData, categorySlug, brandName, storeId: forcedStoreId, }) {
30
32
  const table = useUrlTable({ defaults: { pageSize: "24", sort: DEFAULT_SORT } });
33
+ const { canSeePrices } = useCanSeePrices();
31
34
  const [searchInput, setSearchInput] = useState(table.get(TABLE_KEYS.QUERY) || "");
32
35
  const [filterOpen, setFilterOpen] = useState(false);
33
36
  const availability = table.get(TABLE_KEYS.AVAILABILITY) ||
@@ -82,7 +85,7 @@ export function PrizeDrawsIndexListing({ initialData, categorySlug, brandName, s
82
85
  if (e.key === "Enter")
83
86
  commitSearch();
84
87
  };
85
- return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: filterActiveCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search prize draws...", onSearchChange: setSearchInput, onSearchCommit: commitSearch, onSearchKeyDown: handleSearchKeyDown, sortValue: table.get(TABLE_KEYS.SORT) || DEFAULT_SORT, sortOptions: PRIZE_DRAW_SORT_OPTIONS, onSortChange: (v) => {
88
+ return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: filterActiveCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search prize draws...", onSearchChange: setSearchInput, onSearchCommit: commitSearch, onSearchKeyDown: handleSearchKeyDown, sortValue: table.get(TABLE_KEYS.SORT) || DEFAULT_SORT, sortOptions: withoutPriceSorts(PRIZE_DRAW_SORT_OPTIONS, canSeePrices), onSortChange: (v) => {
86
89
  table.set(TABLE_KEYS.SORT, v);
87
90
  }, view: view, onViewChange: (v) => {
88
91
  if (v === VIEW_MODE.TABLE)
@@ -9,7 +9,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  * Firebase Function at draw expiry/sellout ("scheduled" mode). This modal is
10
10
  * a pure display: "pending" (no result yet) or "won" (order.prizeWon is set).
11
11
  */
12
- import { Anchor, Div, Heading, Modal, Span, Stack, Text } from "../../../ui";
12
+ import { Anchor, Div, Heading, Modal, PricesOnly, Span, Stack, Text } from "../../../ui";
13
13
  import { MediaImage } from "../../media/MediaImage";
14
14
  import { PrizeDrawCollage } from "./PrizeDrawCollage";
15
15
  const __P = {
@@ -26,6 +26,6 @@ export function PrizeRevealModal({ open, onClose, items, initialPrizeWon, reveal
26
26
  // audit-content-alignment-ok: informational reveal-status panel, not marketing content
27
27
  _jsxs(Div, { className: `bg-[var(--appkit-color-surface-muted)] ${__P.p4} text-center`, rounded: "default", children: [_jsx(Text, { size: "lg", weight: "semibold", children: "Reveal pending" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: PENDING_COPY[revealMode] })] })) : (
28
28
  // audit-content-alignment-ok: theatrical reveal-modal centerpiece, not marketing content
29
- _jsxs(Div, { className: `border-2 border-[var(--appkit-color-primary)] bg-[var(--appkit-color-surface)] ${__P.p4} text-center`, rounded: "lg", children: [_jsx(Text, { className: "tracking-wider text-[var(--appkit-color-text-muted)]", size: "xs", transform: "uppercase", children: "You won" }), _jsxs(Heading, { level: 2, className: "my-2", children: ["#", winner.itemNumber, " \u2014 ", winner.title] }), winnerImg ? (_jsx(Div, { className: "relative mx-auto h-64 w-64 max-w-full", rounded: "md", overflow: "hidden", children: _jsx(MediaImage, { src: winnerImg, alt: winner.title, size: "hero", objectFit: "contain" }) })) : null, winner.estimatedValue != null ? (_jsxs(Text, { className: "mt-2 text-[var(--appkit-color-text-muted)]", size: "sm", children: ["Estimated value: \u20B9", winner.estimatedValue.toLocaleString("en-IN")] })) : null] })), _jsxs(Div, { textSize: "xs", className: "border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-muted)] text-[var(--appkit-color-text-muted)]", padding: "inlineSm", rounded: "default", children: [_jsx(Span, { weight: "bold", children: "Fairness guarantee:" }), " ", "Winners are picked by ", _jsx("code", { children: "crypto.randomInt" }), " running on LetItRip's server the moment your prize is assigned. Neither the store nor LetItRip staff can influence the outcome.", " ", rngSourceUrl ? (_jsx(Anchor, { href: rngSourceUrl, tone: "none", underline: "always", children: "View RNG source code \u2192" })) : null] })] }) }));
29
+ _jsxs(Div, { className: `border-2 border-[var(--appkit-color-primary)] bg-[var(--appkit-color-surface)] ${__P.p4} text-center`, rounded: "lg", children: [_jsx(Text, { className: "tracking-wider text-[var(--appkit-color-text-muted)]", size: "xs", transform: "uppercase", children: "You won" }), _jsxs(Heading, { level: 2, className: "my-2", children: ["#", winner.itemNumber, " \u2014 ", winner.title] }), winnerImg ? (_jsx(Div, { className: "relative mx-auto h-64 w-64 max-w-full", rounded: "md", overflow: "hidden", children: _jsx(MediaImage, { src: winnerImg, alt: winner.title, size: "hero", objectFit: "contain" }) })) : null, winner.estimatedValue != null ? (_jsx(PricesOnly, { children: _jsxs(Text, { className: "mt-2 text-[var(--appkit-color-text-muted)]", size: "sm", children: ["Estimated value: \u20B9", winner.estimatedValue.toLocaleString("en-IN")] }) })) : null] })), _jsxs(Div, { textSize: "xs", className: "border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-muted)] text-[var(--appkit-color-text-muted)]", padding: "inlineSm", rounded: "default", children: [_jsx(Span, { weight: "bold", children: "Fairness guarantee:" }), " ", "Winners are picked by ", _jsx("code", { children: "crypto.randomInt" }), " running on LetItRip's server the moment your prize is assigned. Neither the store nor LetItRip staff can influence the outcome.", " ", rngSourceUrl ? (_jsx(Anchor, { href: rngSourceUrl, tone: "none", underline: "always", children: "View RNG source code \u2192" })) : null] })] }) }));
30
30
  }
31
31
  export default PrizeRevealModal;
@@ -13,6 +13,7 @@ import { useAuthGate } from "../../../react/hooks/useAuthGate";
13
13
  import { ACTION_ID } from "../constants/action-defs";
14
14
  import { useBottomActions } from "../../layout/hooks/useBottomActions";
15
15
  import { formatCurrency } from "../../../utils/number.formatter";
16
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
16
17
  import { normalizeError } from "../../../errors/normalize";
17
18
  /**
18
19
  * Wires the three primary product-detail CTAs (Buy Now / Add to Cart / Wishlist)
@@ -23,6 +24,7 @@ export function ProductDetailActions({ productId, productTitle, productImage, pr
23
24
  const router = useRouter();
24
25
  const { showToast } = useToast();
25
26
  const { requireAuth, isAuthResolving, modalOpen, modalMessage, closeModal } = useAuthGate();
27
+ const { canSeePrices } = useCanSeePrices();
26
28
  const [busy, setBusy] = useState(null);
27
29
  const [wishlisted, setWishlisted] = useState(false);
28
30
  // `isAuthResolving` covers the window where the session has not settled and a
@@ -122,9 +124,14 @@ export function ProductDetailActions({ productId, productTitle, productImage, pr
122
124
  onClick: handleBuyNow,
123
125
  },
124
126
  ],
125
- infoLabel: price != null && currency
126
- ? formatCurrency(price, currency)
127
- : undefined,
127
+ // A plain string on the bottom bar, so the gate happens as it is
128
+ // built rather than in a slot. It also doubles as the bar's
129
+ // re-publish key, and the two branches never produce equal text.
130
+ infoLabel: price == null || !currency
131
+ ? undefined
132
+ : canSeePrices
133
+ ? formatCurrency(price, currency)
134
+ : "Sign in to see price",
128
135
  // The bar itself owns responsive visibility now, so this one registrar serves
129
136
  // both breakpoints. The desktop branch below must NOT also register —
130
137
  // useBottomActions is last-mounted-wins and the two would clobber each other.
@@ -5,7 +5,7 @@ import { FilterFacetSection } from "../../filters/FilterFacetSection";
5
5
  import { AsyncFacetSection } from "../../filters/AsyncFacetSection";
6
6
  import { RangeFilter } from "../../filters/RangeFilter";
7
7
  import { SwitchFilter } from "../../filters/SwitchFilter";
8
- import { Div } from "../../../ui";
8
+ import { Div, PricesOnly } from "../../../ui";
9
9
  import { TABLE_KEYS } from "../../../constants/table-keys";
10
10
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
11
11
  import { STANDARD_SORT_OPTIONS, STANDARD_PUBLIC_SORT_OPTIONS, } from "../constants/sieve";
@@ -107,5 +107,5 @@ export function ProductFilters({ table, categoryOptions = [], brandOptions = [],
107
107
  table.set(TABLE_KEYS.LISTING_TYPE, listingTypeOptions
108
108
  .filter((o) => vals.includes(o.value))
109
109
  .map((o) => o.value)
110
- .join("|")), searchable: false, defaultCollapsed: false }), listingTypeFooter] })), 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 }))] }));
110
+ .join("|")), searchable: false, defaultCollapsed: false }), listingTypeFooter] })), 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(PricesOnly, { children: _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 }))] }));
111
111
  }
@@ -4,7 +4,7 @@ import React from "react";
4
4
  import Link from "next/link";
5
5
  import { useRouter } from "next/navigation";
6
6
  import { ROUTES } from "../../../next";
7
- import { BaseListingCard, Button, Div, Grid, PriceDisplay, Row, Span, Stack, Text } from "../../../ui";
7
+ import { BaseListingCard, Button, Div, GatedPrice, Grid, PriceDisplay, PricesOnly, Row, Span, Stack, Text } from "../../../ui";
8
8
  import { MediaImage } from "../../media/MediaImage";
9
9
  import { formatCurrency } from "../../../utils/number.formatter";
10
10
  import { getDefaultCurrency } from "../../../core/baseline-resolver";
@@ -142,7 +142,10 @@ function ProductListRow({ product, onClick, onAddToWishlist, isWishlisted, }) {
142
142
  onClick ? "cursor-pointer" : "",
143
143
  ]
144
144
  .filter(Boolean)
145
- .join(" "), children: [_jsx(Div, { className: `flex-shrink-0 w-16 h-16 sm:w-20 sm:h-20 ${__O.hidden} bg-[var(--appkit-color-surface)]`, rounded: "lg", children: product.mainImage ? (_jsx(MediaImage, { src: product.mainImage, alt: product.title, size: "thumbnail" })) : (_jsx(Div, { className: "w-full h-full bg-neutral-200" })) }), _jsxs(Stack, { className: "flex-1 min-w-0 gap-[2px]", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", truncate: 2, children: product.title }), (product.categoryName || product.brand) && (_jsx(Span, { className: "text-[11px] truncate", color: "faint", children: [product.categoryName, product.brand].filter(Boolean).join(" · ") })), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId, className: "text-[11px]" }), _jsxs(Row, { className: "mt-0.5", align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "sm", weight: "semibold", className: "text-primary", children: formatCurrency(product.price, getDefaultCurrency()) }), discount && (_jsxs(Span, { weight: "bold", className: CLS_DISCOUNT_TEXT_BARE, children: ["-", discount, "%"] })), product.rating !== undefined && (_jsxs(Span, { layout: "flex", gap: "2xs", className: "text-[11px]", color: "faint", children: [_jsx(Span, { className: CLS_STAR_BARE, children: "\u2605" }), product.rating.toFixed(1)] })), (() => {
145
+ .join(" "), children: [_jsx(Div, { className: `flex-shrink-0 w-16 h-16 sm:w-20 sm:h-20 ${__O.hidden} bg-[var(--appkit-color-surface)]`, rounded: "lg", children: product.mainImage ? (_jsx(MediaImage, { src: product.mainImage, alt: product.title, size: "thumbnail" })) : (_jsx(Div, { className: "w-full h-full bg-neutral-200" })) }), _jsxs(Stack, { className: "flex-1 min-w-0 gap-[2px]", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", truncate: 2, children: product.title }), (product.categoryName || product.brand) && (_jsx(Span, { className: "text-[11px] truncate", color: "faint", children: [product.categoryName, product.brand].filter(Boolean).join(" · ") })), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId, className: "text-[11px]" }), _jsxs(Row, { className: "mt-0.5", align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "sm", weight: "semibold", className: "text-primary", children: _jsx(GatedPrice, { children: formatCurrency(product.price, getDefaultCurrency()) }) }), discount && (
146
+ // The discount % is derived from price vs originalPrice, so it is a
147
+ // money disclosure even though it carries no currency symbol.
148
+ _jsx(PricesOnly, { children: _jsxs(Span, { weight: "bold", className: CLS_DISCOUNT_TEXT_BARE, children: ["-", discount, "%"] }) })), product.rating !== undefined && (_jsxs(Span, { layout: "flex", gap: "2xs", className: "text-[11px]", color: "faint", children: [_jsx(Span, { className: CLS_STAR_BARE, children: "\u2605" }), product.rating.toFixed(1)] })), (() => {
146
149
  const stock = product.stockCount ?? product.stockQuantity ?? product.availableQuantity;
147
150
  if (stock === undefined)
148
151
  return null;
@@ -8,6 +8,8 @@ import { useProducts } from "../hooks/useProducts";
8
8
  import { BulkActionBar, Div, FilterDrawer, Grid, ListingToolbar, LoginRequiredModal, Pagination, Row, Stack, useToast, StickyToolbar } from "../../../ui";
9
9
  import { usePendingTable } from "../../../react/hooks/usePendingTable";
10
10
  import { useAuthGate } from "../../../react/hooks/useAuthGate";
11
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
12
+ import { withoutPriceSorts } from "../constants/sieve";
11
13
  import { ACTION_ID, ACTION_META, COMPARE_MAX_ITEMS } from "../constants/action-defs";
12
14
  import { CompareOverlay } from "./CompareOverlay";
13
15
  import { ROUTES } from "../../../next";
@@ -56,6 +58,10 @@ function dedicatedPageFor(types) {
56
58
  const plugin = pluginFor(types[0]);
57
59
  if (!plugin.browseRoute)
58
60
  return null;
61
+ // `standard`'s browseRoute IS /products, so without this the page offers
62
+ // "Full Products filters →" pointing at itself.
63
+ if (plugin.browseRoute === String(ROUTES.PUBLIC.PRODUCTS))
64
+ return null;
59
65
  return {
60
66
  href: plugin.browseRoute,
61
67
  label: `Full ${plugin.pluralLabel} filters →`,
@@ -66,6 +72,7 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
66
72
  const table = useUrlTable({ defaults: { pageSize: "24", sort: DEFAULT_SORT } });
67
73
  const { showToast } = useToast();
68
74
  const { requireAuth, modalOpen, modalMessage, closeModal } = useAuthGate();
75
+ const { canSeePrices } = useCanSeePrices();
69
76
  const [searchInput, setSearchInput] = useState(table.get(TABLE_KEYS.QUERY) || "");
70
77
  const [filterOpen, setFilterOpen] = useState(false);
71
78
  const [compareIds, setCompareIds] = useState([]);
@@ -81,7 +88,11 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
81
88
  // "Ending Soon"; pick Auctions + Products and you get only what both
82
89
  // support, because a sort on `auctionEndDate` would order the products
83
90
  // arbitrarily (and needs an index nobody declares for that shape).
84
- const sortOptions = commonSortOptionsFor(effectiveTypes, "public");
91
+ // Price sorts are dropped for signed-out visitors for the same reason the
92
+ // price-range facet is hidden: ordering by price discloses the relative
93
+ // ranking of every hidden amount, and combined with paging it recovers a
94
+ // usable price list. `withoutPriceSorts` is a no-op once signed in.
95
+ const sortOptions = withoutPriceSorts(commonSortOptionsFor(effectiveTypes, "public"), canSeePrices);
85
96
  const sortValue = table.get(TABLE_KEYS.SORT) || DEFAULT_SORT;
86
97
  // Guard against a sort carried over from a previous selection that the new
87
98
  // one can't satisfy — that combination throws FAILED_PRECONDITION in
@@ -7,6 +7,7 @@ import { PRODUCT_FIELDS } from "../../../constants/field-names";
7
7
  import { TABLE_KEYS } from "../../../constants/table-keys";
8
8
  import { sortBy } from "../../../constants/sort";
9
9
  import { GENERIC_PRODUCT_LISTING_TYPES } from "../constants/listing-tabs";
10
+ import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
10
11
  import { listPublicProducts, parsePublicProductParams, defaultAvailabilityForListingTypes, } from "../../../_internal/server/features/products/list-public";
11
12
  import { parseSelectedListingTypes } from "../utils/listing-type";
12
13
  const DEFAULT_PAGE_SIZE = 24;
@@ -18,6 +19,31 @@ export async function ProductsIndexPageView({ searchParams = {} }) {
18
19
  // (Root Cause #30).
19
20
  const raw = searchParams[TABLE_KEYS.LISTING_TYPE];
20
21
  const selectedTypes = parseSelectedListingTypes(Array.isArray(raw) ? raw[0] : raw);
22
+ // A selection naming ONLY types outside the general catalogue (a bookmark or
23
+ // an inbound link from before /products was narrowed) must be sent to the
24
+ // dedicated page that owns them. Without this, `narrowed = selected ∩ span`
25
+ // is empty and listPublicProducts falls back to the FULL span — so
26
+ // /products?listingType=auction would silently render the general catalogue
27
+ // and look like the auctions had vanished.
28
+ //
29
+ // Only redirect when the out-of-span types agree on one destination: `art`
30
+ // and `stickers` both point at /art, so that pair still resolves, whereas
31
+ // auction+pre-order genuinely has no single home and is better served by
32
+ // dropping the filter.
33
+ const outOfSpan = selectedTypes.filter((t) => !GENERIC_PRODUCT_LISTING_TYPES.includes(t));
34
+ if (outOfSpan.length > 0 && outOfSpan.length === selectedTypes.length) {
35
+ const destinations = new Set(outOfSpan.map((t) => pluginFor(t).browseRoute).filter((r) => Boolean(r)));
36
+ if (destinations.size === 1) {
37
+ const [destination] = [...destinations];
38
+ // Carry the search term only. Sort keys are per-type (Root Cause #63) —
39
+ // forwarding a /products sort to /auctions would name a field that page's
40
+ // config never offers, which drops the sort silently rather than loudly.
41
+ const q = searchParams[TABLE_KEYS.QUERY];
42
+ const term = (Array.isArray(q) ? q[0] : q)?.trim();
43
+ const { redirect } = await import("next/navigation");
44
+ redirect(term ? `${destination}?${TABLE_KEYS.QUERY}=${encodeURIComponent(term)}` : destination);
45
+ }
46
+ }
21
47
  const effectiveTypes = selectedTypes.length > 0 ? selectedTypes : GENERIC_PRODUCT_LISTING_TYPES;
22
48
  // See ArtStickersListView — one shared filter implementation with
23
49
  // /api/products, so SSR and the client refetch agree by construction.