@mohasinac/appkit 4.37.1 → 4.38.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 (126) hide show
  1. package/dist/_internal/server/features/bundles/og.d.ts +0 -1
  2. package/dist/_internal/server/features/bundles/og.js +2 -18
  3. package/dist/_internal/server/features/catalogue/og.d.ts +0 -1
  4. package/dist/_internal/server/features/catalogue/og.js +1 -9
  5. package/dist/_internal/server/features/classified/og.d.ts +0 -1
  6. package/dist/_internal/server/features/classified/og.js +1 -9
  7. package/dist/_internal/server/features/digital-code/og.d.ts +0 -1
  8. package/dist/_internal/server/features/digital-code/og.js +1 -9
  9. package/dist/_internal/server/features/grouped/og.d.ts +7 -4
  10. package/dist/_internal/server/features/grouped/og.js +7 -6
  11. package/dist/_internal/server/features/item-requests/og.d.ts +0 -1
  12. package/dist/_internal/server/features/item-requests/og.js +2 -18
  13. package/dist/_internal/server/features/live/og.d.ts +0 -1
  14. package/dist/_internal/server/features/live/og.js +1 -9
  15. package/dist/_internal/server/features/prize-draws/og.js +5 -10
  16. package/dist/_internal/server/features/products/og.js +5 -2
  17. package/dist/_internal/server/features/seo/sitemap.js +10 -2
  18. package/dist/_internal/server/features/shared/listing-adapters.d.ts +1 -2
  19. package/dist/_internal/server/features/shared/listing-adapters.js +5 -3
  20. package/dist/_internal/shared/listing-types/_registry.d.ts +31 -4
  21. package/dist/_internal/shared/listing-types/art/config.d.ts +1 -0
  22. package/dist/_internal/shared/listing-types/art/config.js +2 -0
  23. package/dist/_internal/shared/listing-types/auction/config.d.ts +1 -0
  24. package/dist/_internal/shared/listing-types/auction/config.js +3 -0
  25. package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -0
  26. package/dist/_internal/shared/listing-types/classified/config.js +3 -0
  27. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -0
  28. package/dist/_internal/shared/listing-types/digital-code/config.js +3 -0
  29. package/dist/_internal/shared/listing-types/live/config.d.ts +1 -0
  30. package/dist/_internal/shared/listing-types/live/config.js +3 -0
  31. package/dist/_internal/shared/listing-types/pre-order/config.d.ts +1 -0
  32. package/dist/_internal/shared/listing-types/pre-order/config.js +2 -0
  33. package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -0
  34. package/dist/_internal/shared/listing-types/prize-draw/config.js +3 -0
  35. package/dist/_internal/shared/listing-types/standard/config.d.ts +1 -0
  36. package/dist/_internal/shared/listing-types/standard/config.js +2 -0
  37. package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -0
  38. package/dist/_internal/shared/listing-types/stickers/config.js +2 -0
  39. package/dist/client/observability/reportClientError.js +11 -1
  40. package/dist/client.d.ts +4 -0
  41. package/dist/client.js +4 -0
  42. package/dist/core/hooks/useSyncManager.js +11 -1
  43. package/dist/features/account/hooks/useNotifications.js +25 -5
  44. package/dist/features/auctions/components/AuctionBottomActions.js +10 -2
  45. package/dist/features/auctions/components/AuctionDetailPageView.js +12 -3
  46. package/dist/features/auctions/components/LiveBidPrice.js +2 -2
  47. package/dist/features/auctions/components/LiveMinIncrement.d.ts +1 -1
  48. package/dist/features/auctions/components/LiveMinIncrement.js +8 -0
  49. package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -3
  50. package/dist/features/auctions/components/PlaceBidFormClient.js +16 -1
  51. package/dist/features/auctions/hooks/useAuctions.js +12 -1
  52. package/dist/features/auctions/hooks/useRealtimeBids.d.ts +3 -0
  53. package/dist/features/auctions/hooks/useRealtimeBids.js +138 -36
  54. package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +2 -2
  55. package/dist/features/catalogue/components/PublicCatalogueView.js +2 -2
  56. package/dist/features/categories/components/BundleCollage.js +10 -5
  57. package/dist/features/categories/components/BundleDetailView.js +7 -3
  58. package/dist/features/categories/components/CategoryProductsListing.js +4 -1
  59. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -2
  60. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -2
  61. package/dist/features/homepage/components/FeaturedBundlesSection.js +4 -4
  62. package/dist/features/live/components/LiveItemDetailPageView.js +2 -2
  63. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -3
  64. package/dist/features/pre-orders/components/PreOrderActionsClient.js +2 -2
  65. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -2
  66. package/dist/features/pre-orders/components/PreOrdersIndexListing.js +4 -1
  67. package/dist/features/products/actions/product-actions.d.ts +9 -0
  68. package/dist/features/products/actions/product-actions.js +11 -0
  69. package/dist/features/products/columns/productTableColumns.js +2 -2
  70. package/dist/features/products/components/AuctionsIndexListing.js +4 -1
  71. package/dist/features/products/components/BidHistory.js +2 -2
  72. package/dist/features/products/components/CompareOverlay.js +2 -2
  73. package/dist/features/products/components/GroupMemberPicker.js +5 -5
  74. package/dist/features/products/components/ListingBottomActions.js +12 -3
  75. package/dist/features/products/components/MakeOfferButton.js +7 -1
  76. package/dist/features/products/components/MarketplaceBundleCard.js +4 -4
  77. package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -2
  78. package/dist/features/products/components/PrizeDrawCollage.js +16 -5
  79. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
  80. package/dist/features/products/components/PrizeDrawsIndexListing.js +4 -1
  81. package/dist/features/products/components/PrizeRevealModal.js +2 -2
  82. package/dist/features/products/components/ProductDetailActions.js +10 -3
  83. package/dist/features/products/components/ProductFilters.js +2 -2
  84. package/dist/features/products/components/ProductGrid.js +5 -2
  85. package/dist/features/products/components/ProductsIndexListing.js +12 -1
  86. package/dist/features/products/components/ProductsIndexPageView.js +26 -0
  87. package/dist/features/products/components/ShowGroupSection.js +2 -2
  88. package/dist/features/products/components/SublistingCarouselSection.js +2 -2
  89. package/dist/features/products/constants/listing-tabs.d.ts +36 -12
  90. package/dist/features/products/constants/listing-tabs.js +38 -14
  91. package/dist/features/products/constants/sieve.d.ts +14 -0
  92. package/dist/features/products/constants/sieve.js +39 -0
  93. package/dist/features/tester/seed-data/authored/buying__bidding.js +5 -4
  94. package/dist/features/tester/seed-data/authored/buying__browsing-search.js +85 -85
  95. package/dist/features/tester/seed-data/authored/buying__image-tile-layout.js +2 -2
  96. package/dist/features/tester/seed-data/authored/buying__product-detail.js +53 -15
  97. package/dist/features/tester/seed-data/authored/content-discovery__search.js +6 -4
  98. package/dist/features/tester/seed-data/authored/design-ux__carousel-arrow-bounds.js +4 -4
  99. package/dist/features/tester/seed-data/authored/design-ux__homepage-carousels.js +2 -2
  100. package/dist/features/tester/seed-data/authored/design-ux__status-badge-legibility.js +4 -4
  101. package/dist/features/tester/seed-data/authored/design-ux__sticky-cta-bar.js +8 -8
  102. package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +8 -8
  103. package/dist/features/tester/seed-data/authored/seo__canonical-and-host.js +16 -0
  104. package/dist/features/tester/seed-data/authored/seo__og-images.js +17 -0
  105. package/dist/features/tester/seed-data/stores-tester-seed-data.js +41 -2
  106. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +36 -6
  107. package/dist/features/wishlist/components/WishlistPage.js +2 -2
  108. package/dist/index.d.ts +2 -1
  109. package/dist/index.js +4 -1
  110. package/dist/react/contexts/SessionContext.js +40 -1
  111. package/dist/react/hooks/useCanSeePrices.d.ts +37 -0
  112. package/dist/react/hooks/useCanSeePrices.js +40 -0
  113. package/dist/seed/addresses-seed-data.js +36 -2
  114. package/dist/seed/users-seed-data.js +10 -2
  115. package/dist/seo/json-ld.d.ts +21 -0
  116. package/dist/seo/json-ld.js +45 -0
  117. package/dist/styles.css +41 -0
  118. package/dist/ui/components/GatedPrice.d.ts +66 -0
  119. package/dist/ui/components/GatedPrice.js +80 -0
  120. package/dist/ui/components/GatedPrice.style.css +40 -0
  121. package/dist/ui/components/PriceDisplay.d.ts +12 -0
  122. package/dist/ui/components/PriceDisplay.js +17 -17
  123. package/dist/ui/components/index.style.css +1 -0
  124. package/dist/ui/index.d.ts +2 -0
  125. package/dist/ui/index.js +1 -0
  126. package/package.json +1 -1
@@ -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.
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useState } from "react";
4
4
  import Link from "next/link";
5
- import { Div, Row, Span, Text, Modal, SideDrawer, Button } from "../../../ui";
5
+ import { Div, PricesOnly, Row, Span, Text, Modal, SideDrawer, Button } from "../../../ui";
6
6
  import { MediaImage } from "../../media/MediaImage";
7
7
  import { GroupMemberPicker } from "./GroupMemberPicker";
8
8
  import { formatCurrency } from "../../../utils/number.formatter";
@@ -23,7 +23,7 @@ function MemberThumb({ member, isCurrent }) {
23
23
  const price = formatCurrency(member.price, member.currency ?? "INR");
24
24
  return (_jsxs(Link, { href: href, "aria-label": member.title, className: "flex flex-col items-center gap-[var(--appkit-space-1-5)] flex-shrink-0 w-16 group", children: [_jsxs(Div, { rounded: "full", className: `relative w-14 h-14 overflow-hidden border-2 transition-all ${isCurrent
25
25
  ? "border-[var(--appkit-color-primary)] ring-2 ring-[var(--appkit-color-primary)]/30"
26
- : "border-[var(--appkit-color-border)] group-hover:border-[var(--appkit-color-primary)]"}`, children: [_jsx(MediaImage, { src: image, alt: member.title, size: "card" }), member.isGroupParent && (_jsx(Span, { color: "inverse", rounded: "sm", className: "absolute bottom-0 right-0 bg-[var(--appkit-color-primary)] text-[8px] leading-none px-[0.25rem] py-[0.125rem]", children: "Set" }))] }), _jsx(Text, { className: "text-[10px] leading-tight line-clamp-2 w-full", color: "muted", align: "start", children: member.title }), _jsx(Text, { className: "text-[10px]", color: "primary", weight: "semibold", children: price })] }));
26
+ : "border-[var(--appkit-color-border)] group-hover:border-[var(--appkit-color-primary)]"}`, children: [_jsx(MediaImage, { src: image, alt: member.title, size: "card" }), member.isGroupParent && (_jsx(Span, { color: "inverse", rounded: "sm", className: "absolute bottom-0 right-0 bg-[var(--appkit-color-primary)] text-[8px] leading-none px-[0.25rem] py-[0.125rem]", children: "Set" }))] }), _jsx(Text, { className: "text-[10px] leading-tight line-clamp-2 w-full", color: "muted", align: "start", children: member.title }), _jsx(Text, { className: "text-[10px]", color: "primary", weight: "semibold", children: _jsx(PricesOnly, { children: price }) })] }));
27
27
  }
28
28
  export function ShowGroupSection({ groupId, currentSlug, isParent, groupTitle }) {
29
29
  const [open, setOpen] = useState(false);
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useState } from "react";
4
4
  import Link from "next/link";
5
5
  import { ROUTES } from "../../../next";
6
- import { Div, Row, Span, Text } from "../../../ui";
6
+ import { Div, PricesOnly, Row, Span, Text } from "../../../ui";
7
7
  import { MediaImage } from "../../media/MediaImage";
8
8
  import { formatCurrency } from "../../../utils/number.formatter";
9
9
  import { normalizeListingType } from "../utils/listing-type";
@@ -23,7 +23,7 @@ function ListingThumb({ listing, isCurrent, }) {
23
23
  const price = formatCurrency(listing.price, listing.currency ?? "INR");
24
24
  return (_jsxs(Link, { href: href, "aria-label": listing.title, className: "flex flex-col items-center gap-[var(--appkit-space-1-5)] flex-shrink-0 w-16 group", children: [_jsx(Div, { rounded: "full", className: `relative w-14 h-14 overflow-hidden border-2 transition-all ${isCurrent
25
25
  ? "border-[var(--appkit-color-primary)] ring-2 ring-[var(--appkit-color-primary)]/30"
26
- : "border-[var(--appkit-color-border)] group-hover:border-[var(--appkit-color-primary)]"}`, children: image ? (_jsx(MediaImage, { src: image, alt: listing.title, size: "avatar" })) : (_jsx(Row, { textSize: "xs", color: "faint", className: "w-full h-full", surface: "subtle", align: "center", justify: "center", children: "\u00E2\u2014\u00AF" })) }), _jsx(Text, { className: "text-[10px] leading-tight line-clamp-2 w-full", color: "muted", align: "start", children: listing.title }), _jsx(Text, { className: "text-[10px]", color: "primary", weight: "semibold", children: price })] }));
26
+ : "border-[var(--appkit-color-border)] group-hover:border-[var(--appkit-color-primary)]"}`, children: image ? (_jsx(MediaImage, { src: image, alt: listing.title, size: "avatar" })) : (_jsx(Row, { textSize: "xs", color: "faint", className: "w-full h-full", surface: "subtle", align: "center", justify: "center", children: "\u00E2\u2014\u00AF" })) }), _jsx(Text, { className: "text-[10px] leading-tight line-clamp-2 w-full", color: "muted", align: "start", children: listing.title }), _jsx(Text, { className: "text-[10px]", color: "primary", weight: "semibold", children: _jsx(PricesOnly, { children: price }) })] }));
27
27
  }
28
28
  export function SublistingCarouselSection({ sublistingCategoryId, currentListingId }) {
29
29
  const [open, setOpen] = useState(false);
@@ -33,16 +33,33 @@ export interface ListingTab {
33
33
  entity?: "stores";
34
34
  }
35
35
  /**
36
- * The generic `/products` browse page spans EVERY listing type (2026-08-21).
36
+ * The types the generic `/products` browse page spans — the GENERAL CATALOGUE,
37
+ * not an index of everything.
37
38
  *
38
- * It used to span only `standard|classified|digital-code|live` on the theory
39
- * that auctions/pre-orders/prize-draws/art each had a dedicated page — but
40
- * that made five of nine types unreachable from the main catalogue, broke
41
- * seven legacy `/search/.../tab/<type>` permanent redirects that all point at
42
- * `/products`, and made the cross-type Compare + bulk-cart features (which
43
- * only exist on this page) unusable for those types. The dedicated pages
44
- * remain, and `/products` links to them when a single time-boxed type is
45
- * selected.
39
+ * DERIVED from `ListingTypePlugin.inGeneralCatalogue`, never hand-written: a
40
+ * literal array here is how ten enumerations of one union drifted apart in
41
+ * Root Cause #61, and `audit-listing-type-tab-coverage`'s STAYS_DERIVED rule
42
+ * blocks one.
43
+ *
44
+ * ## History — this spanned all nine types from 2026-08-21 until now
45
+ *
46
+ * The widening was a fix for Root Cause #61 and cited three reasons. Two have
47
+ * since been solved elsewhere, and the third is an accepted cost:
48
+ *
49
+ * 1. "Five of nine types unreachable from the main catalogue." Still true for
50
+ * `classified`/`digital-code`/`live`, which have dedicated pages but no
51
+ * `MAIN_NAV_ITEMS` entry — which is exactly why `inGeneralCatalogue` keeps
52
+ * those three here. The other five are all in the main nav.
53
+ * 2. "Seven legacy `/search/.../tab/<type>` redirects all point at /products."
54
+ * No longer true: that route derives `tabSlug -> browseRoute` from this
55
+ * same registry, and `/search` maps every resource type to its own page.
56
+ * Neither falls through to `/products` for a typed request.
57
+ * 3. "Cross-type Compare + bulk-cart only exist on this page." Still true, and
58
+ * accepted: you can no longer compare an auction against a standard product.
59
+ *
60
+ * A type appearing both here and in the main nav is the bug this fixes — the
61
+ * same item reachable from two places with two different chromes (an auction
62
+ * card in the general grid renders Buy-Now chrome, not bid affordances).
46
63
  */
47
64
  export declare const GENERIC_PRODUCT_LISTING_TYPES: readonly ListingType[];
48
65
  /** The listing types the combined `/art` page spans. */
@@ -60,9 +77,16 @@ export type SellerListingTabId = string;
60
77
  * In-page type-filter chips on the generic `/products` browse page.
61
78
  *
62
79
  * Multi-select (checkbox semantics) — no "All" sentinel chip, because "none
63
- * checked" already means "every type". A sentinel would be a second way to
64
- * express the same state and would have to be cleared whenever a real chip
65
- * was ticked.
80
+ * checked" already means "every type IN THE GENERAL CATALOGUE". A sentinel
81
+ * would be a second way to express the same state and would have to be cleared
82
+ * whenever a real chip was ticked.
83
+ *
84
+ * 🛑 Derived from `GENERIC_PRODUCT_LISTING_TYPES`, NOT `ALL_LISTING_TYPES`. The
85
+ * chips must offer exactly the types the page can actually return: a chip for a
86
+ * type outside the span intersects to empty, and `listPublicProducts` then falls
87
+ * back to the full span — i.e. ticking it would silently widen the results
88
+ * instead of narrowing them. `ADMIN_PRODUCT_LISTING_TYPE_TABS` is a separate
89
+ * constant and correctly stays all-nine.
66
90
  */
67
91
  export declare const PRODUCT_TYPE_FILTER_TABS: readonly ListingTab[];
68
92
  /** In-page type-filter chips on the combined `/art` (Art & Stickers) page. */