@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
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Row, Span } from "../../../ui";
3
+ import { GatedPrice, Row, Span } from "../../../ui";
4
4
  import { formatCurrency } from "../../../utils/number.formatter";
5
5
  import { useLiveAuctionBid } from "../hooks/useLiveAuctionBid";
6
6
  /**
@@ -13,5 +13,5 @@ import { useLiveAuctionBid } from "../hooks/useLiveAuctionBid";
13
13
  */
14
14
  export function LiveBidPrice({ productId, currentBid, bidCount, currency, isEnded, priceSize = "xl", badgeSize = "xs", }) {
15
15
  const live = useLiveAuctionBid(productId, currentBid, bidCount, { enabled: !isEnded });
16
- return (_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { weight: "bold", className: "text-primary-600 dark:text-primary-400", size: priceSize, children: formatCurrency(live.currentBid, currency) }), _jsxs(Span, { size: badgeSize, weight: "medium", rounded: "full", padding: "pill-md", surface: "subtle", color: "muted", children: [live.bidCount, " ", live.bidCount === 1 ? "bid" : "bids"] })] }));
16
+ return (_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { weight: "bold", className: "text-primary-600 dark:text-primary-400", size: priceSize, children: _jsx(GatedPrice, { label: "Sign in to see the current bid", children: formatCurrency(live.currentBid, currency) }) }), _jsxs(Span, { size: badgeSize, weight: "medium", rounded: "full", padding: "pill-md", surface: "subtle", color: "muted", children: [live.bidCount, " ", live.bidCount === 1 ? "bid" : "bids"] })] }));
17
17
  }
@@ -15,4 +15,4 @@ export interface LiveMinIncrementProps {
15
15
  * increment depends on the current bid amount (tiered), so it must keep
16
16
  * resolving against the live current bid, not just the SSR snapshot.
17
17
  */
18
- export declare function LiveMinIncrement({ productId, currentBid, bidCount, currency, isEnded, tiers, minBidIncrementOverride, size, }: LiveMinIncrementProps): import("react").JSX.Element;
18
+ export declare function LiveMinIncrement({ productId, currentBid, bidCount, currency, isEnded, tiers, minBidIncrementOverride, size, }: LiveMinIncrementProps): import("react").JSX.Element | null;
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Span } from "../../../ui";
4
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
4
5
  import { formatCurrency } from "../../../utils/number.formatter";
5
6
  import { useLiveAuctionBid } from "../hooks/useLiveAuctionBid";
6
7
  import { resolveMinBidIncrement, } from "../../../_internal/shared/features/auctions/config";
@@ -13,5 +14,12 @@ import { resolveMinBidIncrement, } from "../../../_internal/shared/features/auct
13
14
  export function LiveMinIncrement({ productId, currentBid, bidCount, currency, isEnded, tiers, minBidIncrementOverride, size = "xs", }) {
14
15
  const live = useLiveAuctionBid(productId, currentBid, bidCount, { enabled: !isEnded });
15
16
  const increment = resolveMinBidIncrement(live.currentBid, tiers, minBidIncrementOverride);
17
+ const { canSeePrices } = useCanSeePrices();
18
+ // The increment is derived from the current bid, so it discloses the bid's
19
+ // magnitude. This is a SECONDARY hint next to the gated bid itself, so it
20
+ // hides outright — "min increment Sign in to see price" reads as broken copy,
21
+ // and the prompt is already on the bid one line up.
22
+ if (!canSeePrices)
23
+ return null;
16
24
  return (_jsxs(Span, { size: size, color: "muted", children: ["min increment ", formatCurrency(increment, currency)] }));
17
25
  }
@@ -12,7 +12,7 @@ import { useCountdown } from "../../../react";
12
12
  import { ROUTES } from "../../../next";
13
13
  import { formatCurrency } from "../../../utils";
14
14
  import { getDefaultCurrency } from "../../../core/baseline-resolver";
15
- import { BaseListingCard, Button, Caption, Div, Row, Span, Stack, Text, TextLink } from "../../../ui";
15
+ import { BaseListingCard, Button, Caption, Div, GatedPrice, Row, Span, Stack, Text, TextLink } from "../../../ui";
16
16
  import { isBuyNowAvailable } from "../../../_internal/shared/features/auctions/config";
17
17
  import { CardStoreLine } from "../../products/components/CardStoreLine";
18
18
  const CLS_STAR_ICON = "h-5 w-5 fill-warning text-warning";
@@ -25,11 +25,11 @@ function renderAuctionCardHero(props) {
25
25
  }
26
26
  function renderAuctionCardInfoList(props) {
27
27
  const { product, auctionHref, displayBid, bidCount, isEnded, remaining, inWishlist, wishlistLoading, wishlistActions, countdownClass, mergedLabels, handleWishlist, handleNavigate } = props;
28
- return (_jsxs(_Fragment, { children: [_jsxs(Row, { className: "min-w-0", align: "start", justify: "between", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: `flex-1 leading-snug transition-colors hover:text-primary`, color: "primary", truncate: 2, size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Text, { className: "text-primary", size: "sm", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), _jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", className: `items-[center] py-[0.125rem] text-[11px] ${countdownClass}`, padding: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-2.5 w-2.5" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), bidCount > 0 && _jsx(Caption, { className: "text-[11px]", children: mergedLabels.totalBids(bidCount) })] }), !isEnded && (_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", className: "self-start px-[0.625rem] mt-0.5", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }))] }));
28
+ return (_jsxs(_Fragment, { children: [_jsxs(Row, { className: "min-w-0", align: "start", justify: "between", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: `flex-1 leading-snug transition-colors hover:text-primary`, color: "primary", truncate: 2, size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Text, { className: "text-primary", size: "sm", weight: "bold", children: _jsx(GatedPrice, { label: "Sign in to see the bid", children: formatCurrency(displayBid, getDefaultCurrency()) }) }), _jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", className: `items-[center] py-[0.125rem] text-[11px] ${countdownClass}`, padding: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-2.5 w-2.5" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), bidCount > 0 && _jsx(Caption, { className: "text-[11px]", children: mergedLabels.totalBids(bidCount) })] }), !isEnded && (_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", className: "self-start px-[0.625rem] mt-0.5", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }))] }));
29
29
  }
30
30
  function renderAuctionCardInfoGrid(props) {
31
31
  const { product, auctionHref, displayBid, bidCount, hasCurrentBid, isEnded, remaining, resolvedBuyoutPrice, inWishlist, wishlistLoading, wishlistActions, countdownClass, mergedLabels, handleWishlist, handleNavigate } = props;
32
- return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "min-w-0 flex-1 leading-snug transition-colors hover:text-primary", color: "primary", size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Div, { children: [_jsx(Caption, { children: isEnded && hasCurrentBid ? mergedLabels.winningBid : hasCurrentBid ? mergedLabels.currentBid : mergedLabels.startingBid }), _jsx(Text, { className: "leading-none text-primary", size: "base", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), isEnded && product.winnerDisplayName && (_jsxs(Caption, { className: "mt-0.5", color: "muted", children: [mergedLabels.wonBy, ": ", maskDisplayName(product.winnerDisplayName)] }))] }), _jsxs(Row, { wrap: true, justify: "between", gap: "sm", className: "gap-x-2 gap-y-1", children: [_jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", textSize: "xs", className: `items-[center] ${countdownClass}`, paddingY: "y-2xs", paddingX: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-3 w-3" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), _jsx(Caption, { children: mergedLabels.totalBids(bidCount) })] }), _jsx(Row, { wrap: true, gap: "xs", className: "mt-auto", children: isEnded ? (_jsx(Button, { gap: "xs", type: "button", variant: "ghost", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1 cursor-not-allowed opacity-60", disabled: true, children: _jsx(Span, { children: mergedLabels.ended }) })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }), resolvedBuyoutPrice ? (
32
+ return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "min-w-0 flex-1 leading-snug transition-colors hover:text-primary", color: "primary", size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Div, { children: [_jsx(Caption, { children: isEnded && hasCurrentBid ? mergedLabels.winningBid : hasCurrentBid ? mergedLabels.currentBid : mergedLabels.startingBid }), _jsx(Text, { className: "leading-none text-primary", size: "base", weight: "bold", children: _jsx(GatedPrice, { label: "Sign in to see the bid", children: formatCurrency(displayBid, getDefaultCurrency()) }) }), isEnded && product.winnerDisplayName && (_jsxs(Caption, { className: "mt-0.5", color: "muted", children: [mergedLabels.wonBy, ": ", maskDisplayName(product.winnerDisplayName)] }))] }), _jsxs(Row, { wrap: true, justify: "between", gap: "sm", className: "gap-x-2 gap-y-1", children: [_jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", textSize: "xs", className: `items-[center] ${countdownClass}`, paddingY: "y-2xs", paddingX: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-3 w-3" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), _jsx(Caption, { children: mergedLabels.totalBids(bidCount) })] }), _jsx(Row, { wrap: true, gap: "xs", className: "mt-auto", children: isEnded ? (_jsx(Button, { gap: "xs", type: "button", variant: "ghost", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1 cursor-not-allowed opacity-60", disabled: true, children: _jsx(Span, { children: mergedLabels.ended }) })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }), resolvedBuyoutPrice ? (
33
33
  /* `secondary`, not `danger` — this button only navigates to the
34
34
  PDP, and `danger` is reserved for destructive actions (Rule #7
35
35
  kinds). ACTIONS.AUCTION["buy-it-now"] is itself `secondary`. */
@@ -5,7 +5,9 @@ import { useRouter } from "next/navigation";
5
5
  import { useTranslations } from "next-intl";
6
6
  import { formatCurrency } from "../../../utils/number.formatter";
7
7
  import { isAuthError } from "../../../utils/auth-error";
8
- import { Button, CountdownDisplay, Div, LoginRequiredModal, Modal, Row, Span, Stack, Text } from "../../../ui";
8
+ import { Button, CountdownDisplay, Div, LoginRequiredModal, Modal, Row, Span, Stack, Text, TextLink } from "../../../ui";
9
+ import { ROUTES } from "../../../next/routing/route-map";
10
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
9
11
  import { Form } from "../../../ui/components/Form";
10
12
  import { FieldInput } from "../../../ui/forms/FieldInput";
11
13
  import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
@@ -36,6 +38,10 @@ const BID_FALLBACK = "Failed to place bid. Please try again.";
36
38
  const BUY_NOW_FALLBACK = "Buy Now failed. Please try again.";
37
39
  export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, startingBid, tiers, minBidIncrementOverride, currency, isEnded, auctionEndDate, buyNowPrice, bidCount: ssrBidCount, tags = [], onPlaceBid, onBuyNow, }) {
38
40
  const router = useRouter();
41
+ // Declared with the other hooks, never inside the early return below — a
42
+ // conditional hook is a render-order violation, and this component early-
43
+ // returns for signed-out viewers.
44
+ const { canSeePrices } = useCanSeePrices();
39
45
  // Live-updates while the bid card is open — reflects other bidders'
40
46
  // activity on this auction, not just the bidder's own submission.
41
47
  const live = useLiveAuctionBid(productId, ssrCurrentBid, ssrBidCount, { enabled: !isEnded });
@@ -219,6 +225,15 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
219
225
  }
220
226
  });
221
227
  }
228
+ // A signed-out visitor cannot place a bid — `useAuthGate` already stops the
229
+ // submit — and this panel is the single densest money surface on the site:
230
+ // current bid, starting bid, minimum, increment, the +N preset chips and the
231
+ // Buy Now price, twelve amounts in all. Replacing the whole panel is both the
232
+ // honest affordance (the form was never usable) and the only version that
233
+ // cannot leak one of the twelve through a string prop.
234
+ if (!canSeePrices) {
235
+ return (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "sm", rounded: "xl", surface: "muted", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Bidding is for members" }), _jsx(Text, { size: "xs", color: "muted", children: "Sign in to see the current bid and place your own." }), _jsx(TextLink, { href: String(ROUTES.AUTH.LOGIN), weight: "semibold", size: "sm", children: "Sign in to bid" })] }));
236
+ }
222
237
  return (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Current bid" : "Starting bid" }), _jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Starting bid" : "Minimum bid" })] }), _jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(Span, { size: "xl", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsx(Span, { size: "sm", color: "muted", children: formatCurrency(hasBids ? startingBid : minBid, currency) })] }), _jsxs(Text, { size: "xs", color: "faint", children: [bidCount, " ", bidCount === 1 ? "bid" : "bids", " \u00B7 min increment", " ", formatCurrency(minBidIncrement, currency)] })] }), _jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to place a bid. Please log in or create an account to continue." }), _jsx(Form, { schema: schema, onSubmit: (e) => {
223
238
  // handler wired through render-prop below — keeps setFieldError in scope
224
239
  e.preventDefault();
@@ -57,7 +57,18 @@ export function useAuctionBids(auctionSlug, limit = 10, opts) {
57
57
  queryKey: ["auction-bids", auctionSlug, limit],
58
58
  queryFn: () => apiClient.get(`${AUCTION_ENDPOINTS.BIDS(auctionSlug)}?limit=${limit}`),
59
59
  enabled: (opts?.enabled ?? true) && !!auctionSlug,
60
- refetchInterval: 15000, // refresh every 15 s for real-time feel
60
+ // 🛑 Do NOT restore a ~15s interval here. Live auction state on the detail
61
+ // page comes from the SSE channel (`useRealtimeBids` -> `useLiveAuctionBid`,
62
+ // consumed by LiveBidPrice / LiveMinIncrement / PlaceBidFormClient), NOT
63
+ // from this hook — so a fast poll here buys no freshness the page does not
64
+ // already have, and duplicates a stream that already costs a held-open
65
+ // function per viewer.
66
+ //
67
+ // Verified 2026-09-14: this hook currently has no call sites at all outside
68
+ // the appkit barrel re-export, so the old 15s interval was billing nothing
69
+ // only by accident. Kept deliberately slow so that adopting it cannot
70
+ // silently reintroduce a per-viewer poll.
71
+ refetchInterval: 60000,
61
72
  });
62
73
  return {
63
74
  bids: data?.items ?? [],
@@ -28,6 +28,9 @@ export interface UseRealtimeBidsReturn {
28
28
  * Uses a streaming route that keeps a server-side listener open and pushes
29
29
  * updates to the browser via text/event-stream. No Firebase client SDK required.
30
30
  *
31
+ * Multiple instances pointed at the same URL share ONE underlying connection —
32
+ * see the `channels` map above for why that matters.
33
+ *
31
34
  * @param productId - The auction product ID to subscribe to, or null to disable
32
35
  * @param getEndpoint - Optional: override the SSE endpoint URL builder.
33
36
  * Defaults to `/api/realtime/bids/{productId}`.
@@ -3,6 +3,126 @@ import { normalizeError } from "../../../errors/normalize";
3
3
  import { useEffect, useState } from "react";
4
4
  import { logger } from "../../../core/Logger";
5
5
  import { BID_ENDPOINTS } from "../../../constants/api-endpoints";
6
+ /**
7
+ * One EventSource per SSE URL, shared by every hook instance that asks for it.
8
+ *
9
+ * 🛑 THIS SHARING IS LOAD-BEARING, NOT AN OPTIMISATION.
10
+ *
11
+ * `/api/realtime/bids/[id]` holds a serverless invocation open for its full
12
+ * 45s TTL per connection (route `maxDuration = 50`, `STREAM_TTL_MS = 45_000`),
13
+ * then self-closes so the browser reconnects. So a connection is not a cheap
14
+ * socket — it is a continuously-billed function.
15
+ *
16
+ * This hook is consumed by `useLiveAuctionBid`, which `LiveBidPrice`,
17
+ * `LiveMinIncrement` and `PlaceBidFormClient` each call independently — and
18
+ * `AuctionDetailPageView` mounts those five times over. Before this map, one
19
+ * auction page view opened FIVE concurrent EventSources to the same URL and
20
+ * therefore pinned five held-open functions, all pushing byte-identical data.
21
+ * That was a material share of the Fluid Active CPU that paused the Vercel
22
+ * account (2026-09-14).
23
+ *
24
+ * Keyed on the resolved URL rather than on productId: `getEndpoint` is an
25
+ * override, and two callers pointing at different endpoints for the same
26
+ * product must not be handed each other's stream.
27
+ */
28
+ const channels = new Map();
29
+ /**
30
+ * Grace period before a zero-ref channel is actually closed.
31
+ *
32
+ * Without it, any remount — a React StrictMode double-invoke in dev, a tab
33
+ * switch inside the page, a re-render that swaps the subtree — tears the
34
+ * stream down and immediately builds a new one, which costs a fresh function
35
+ * invocation each time. The route's own header comment records that an
36
+ * uncontrolled reconnect loop is exactly the failure this endpoint has already
37
+ * had once.
38
+ */
39
+ const CHANNEL_CLOSE_GRACE_MS = 1000;
40
+ function notify(channel) {
41
+ const snapshot = {
42
+ data: channel.data,
43
+ connected: channel.connected,
44
+ };
45
+ for (const subscriber of channel.subscribers)
46
+ subscriber(snapshot);
47
+ }
48
+ function openChannel(url) {
49
+ const es = new EventSource(url);
50
+ const channel = {
51
+ es,
52
+ refCount: 0,
53
+ subscribers: new Set(),
54
+ closeTimer: null,
55
+ data: null,
56
+ connected: false,
57
+ };
58
+ es.onmessage = (event) => {
59
+ try {
60
+ const msg = JSON.parse(event.data);
61
+ if (msg.type === "connected") {
62
+ channel.connected = true;
63
+ }
64
+ else if (msg.type === "update") {
65
+ channel.data = msg.data ?? null;
66
+ channel.connected = true;
67
+ }
68
+ else if (msg.type === "error") {
69
+ channel.connected = false;
70
+ }
71
+ else {
72
+ return;
73
+ }
74
+ notify(channel);
75
+ }
76
+ catch (_err) {
77
+ void normalizeError(_err);
78
+ logger.warn("[useRealtimeBids] Failed to parse SSE message");
79
+ }
80
+ };
81
+ es.onerror = () => {
82
+ // EventSource reconnects on its own, including after the route's
83
+ // deliberate 45s self-close. Report disconnected; do NOT close here, or
84
+ // the built-in reconnect is lost and the stream dies permanently.
85
+ channel.connected = false;
86
+ notify(channel);
87
+ logger.warn("[useRealtimeBids] SSE connection error — will retry");
88
+ };
89
+ return channel;
90
+ }
91
+ function subscribe(url, onState) {
92
+ let channel = channels.get(url);
93
+ if (!channel) {
94
+ channel = openChannel(url);
95
+ channels.set(url, channel);
96
+ }
97
+ if (channel.closeTimer) {
98
+ clearTimeout(channel.closeTimer);
99
+ channel.closeTimer = null;
100
+ }
101
+ channel.refCount += 1;
102
+ channel.subscribers.add(onState);
103
+ // Hand the newcomer whatever the channel already knows, so a component that
104
+ // mounts mid-stream renders the live value immediately instead of showing
105
+ // its SSR fallback until the next push (which may be a whole bid away).
106
+ onState({ data: channel.data, connected: channel.connected });
107
+ return () => {
108
+ const active = channels.get(url);
109
+ if (!active)
110
+ return;
111
+ active.subscribers.delete(onState);
112
+ active.refCount -= 1;
113
+ if (active.refCount > 0)
114
+ return;
115
+ active.closeTimer = setTimeout(() => {
116
+ // Re-read: a new subscriber may have arrived during the grace window,
117
+ // in which case the entry in the map is live again and must survive.
118
+ const current = channels.get(url);
119
+ if (!current || current.refCount > 0)
120
+ return;
121
+ current.es.close();
122
+ channels.delete(url);
123
+ }, CHANNEL_CLOSE_GRACE_MS);
124
+ };
125
+ }
6
126
  /**
7
127
  * useRealtimeBids
8
128
  *
@@ -11,6 +131,9 @@ import { BID_ENDPOINTS } from "../../../constants/api-endpoints";
11
131
  * Uses a streaming route that keeps a server-side listener open and pushes
12
132
  * updates to the browser via text/event-stream. No Firebase client SDK required.
13
133
  *
134
+ * Multiple instances pointed at the same URL share ONE underlying connection —
135
+ * see the `channels` map above for why that matters.
136
+ *
14
137
  * @param productId - The auction product ID to subscribe to, or null to disable
15
138
  * @param getEndpoint - Optional: override the SSE endpoint URL builder.
16
139
  * Defaults to `/api/realtime/bids/{productId}`.
@@ -19,50 +142,29 @@ import { BID_ENDPOINTS } from "../../../constants/api-endpoints";
19
142
  * const { currentBid, bidCount, connected } = useRealtimeBids(productId);
20
143
  */
21
144
  export function useRealtimeBids(productId, getEndpoint = BID_ENDPOINTS.REALTIME) {
22
- const [data, setData] = useState(null);
23
- const [connected, setConnected] = useState(false);
145
+ const [state, setState] = useState({
146
+ data: null,
147
+ connected: false,
148
+ });
24
149
  useEffect(() => {
25
150
  if (!productId || typeof EventSource === "undefined") {
26
- setConnected(false);
151
+ setState({ data: null, connected: false });
27
152
  return;
28
153
  }
29
- const url = getEndpoint(productId);
30
- const es = new EventSource(url);
31
- es.onmessage = (event) => {
32
- try {
33
- const msg = JSON.parse(event.data);
34
- if (msg.type === "connected") {
35
- setConnected(true);
36
- }
37
- else if (msg.type === "update") {
38
- setData(msg.data ?? null);
39
- setConnected(true);
40
- }
41
- else if (msg.type === "error") {
42
- setConnected(false);
43
- }
44
- }
45
- catch (_err) {
46
- void normalizeError(_err);
47
- logger.warn("[useRealtimeBids] Failed to parse SSE message");
48
- }
49
- };
50
- es.onerror = () => {
51
- // EventSource will attempt to reconnect automatically.
52
- setConnected(false);
53
- logger.warn("[useRealtimeBids] SSE connection error — will retry");
54
- };
154
+ const unsubscribe = subscribe(getEndpoint(productId), setState);
55
155
  return () => {
56
- es.close();
57
- setConnected(false);
156
+ unsubscribe();
157
+ setState((prev) => ({ ...prev, connected: false }));
58
158
  };
159
+ // `getEndpoint` is deliberately excluded: callers pass an inline arrow in
160
+ // several places, so including it would resubscribe on every render.
59
161
  // eslint-disable-next-line react-hooks/exhaustive-deps
60
162
  }, [productId]);
61
163
  return {
62
- currentBid: data?.currentBid ?? null,
63
- bidCount: data?.bidCount ?? null,
64
- lastBid: data?.lastBid ?? null,
65
- connected,
66
- updatedAt: data?.updatedAt ?? null,
164
+ currentBid: state.data?.currentBid ?? null,
165
+ bidCount: state.data?.bidCount ?? null,
166
+ lastBid: state.data?.lastBid ?? null,
167
+ connected: state.connected,
168
+ updatedAt: state.data?.updatedAt ?? null,
67
169
  };
68
170
  }
@@ -14,7 +14,16 @@ export async function BlogIndexPageView({ searchParams = {} }) {
14
14
  const page = Number(sp(searchParams, "page")) || 1;
15
15
  const pageSize = Number(sp(searchParams, "pageSize")) || 24;
16
16
  const category = sp(searchParams, "category");
17
- const sieveResult = await safeRead(() => blogRepository.listPublished({ ...(category ? { category } : {}) }, { sorts: sort, page, pageSize }), { route: "/blog", key: "blogPosts.listPublished", fallback: null });
17
+ /*
18
+ * 🛑 `q` MUST be read and forwarded. Without it /blog?q=anything rendered the
19
+ * FULL published list — the same failure the API route's own header records
20
+ * having already fixed on its side, never back-ported here (Root Cause #59).
21
+ * It cannot self-correct on the client either: this result becomes
22
+ * `initialData`, and the listing hook sets `staleTime: Infinity` when given
23
+ * SSR data, so the unfiltered first paint is frozen for that query key.
24
+ */
25
+ const q = sp(searchParams, "q").trim();
26
+ const sieveResult = await safeRead(() => blogRepository.listPublished({ ...(category ? { category } : {}), ...(q ? { search: q } : {}) }, { sorts: sort, page, pageSize }), { route: "/blog", key: "blogPosts.listPublished", fallback: null });
18
27
  // Sandbox posts are public rows by design — not on someone else's first paint.
19
28
  const publicSieve = sieveResult
20
29
  ? { ...sieveResult, items: hidePublicTestData(sieveResult.items) }
@@ -83,9 +83,23 @@ declare class BlogRepository extends BaseRepository<BlogPostDocument> {
83
83
  parseValue: typeof parseSieveDateValue;
84
84
  };
85
85
  };
86
+ /**
87
+ * `search` uses the same `searchTxt` push-down as `listAll` — head token as an
88
+ * `array-contains` clause, remaining tokens AND-refined in memory.
89
+ *
90
+ * 🛑 IT HAD NO SEARCH PARAMETER AT ALL, and that is why /blog?q= was inert.
91
+ * `blogGET` searches via `listAll(model, { search })`, but the SSR view calls
92
+ * THIS method, so the server-rendered list ignored `q` entirely — and because
93
+ * that result is handed to the client as `initialData` under
94
+ * `staleTime: Infinity`, the unfiltered first paint was frozen and never
95
+ * refetched. Root Cause #59: the fix went into the API route and was never
96
+ * back-ported to the SSR view that duplicates its job. The route's own header
97
+ * documents the API-side half of this exact bug.
98
+ */
86
99
  listPublished(opts: {
87
100
  category?: BlogPostCategory;
88
101
  featuredOnly?: boolean;
102
+ search?: string;
89
103
  }, model: SieveModel): Promise<FirebaseSieveResult<BlogPostDocument>>;
90
104
  /** Derived on every write path via `applyWriteHooks`. */
91
105
  protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
@@ -143,7 +143,25 @@ class BlogRepository extends BaseRepository {
143
143
  throw new DatabaseError(`Failed to find posts by author: ${error instanceof Error ? error.message : "Unknown error"}`);
144
144
  }
145
145
  }
146
+ /**
147
+ * `search` uses the same `searchTxt` push-down as `listAll` — head token as an
148
+ * `array-contains` clause, remaining tokens AND-refined in memory.
149
+ *
150
+ * 🛑 IT HAD NO SEARCH PARAMETER AT ALL, and that is why /blog?q= was inert.
151
+ * `blogGET` searches via `listAll(model, { search })`, but the SSR view calls
152
+ * THIS method, so the server-rendered list ignored `q` entirely — and because
153
+ * that result is handed to the client as `initialData` under
154
+ * `staleTime: Infinity`, the unfiltered first paint was frozen and never
155
+ * refetched. Root Cause #59: the fix went into the API route and was never
156
+ * back-ported to the SSR view that duplicates its job. The route's own header
157
+ * documents the API-side half of this exact bug.
158
+ */
146
159
  async listPublished(opts, model) {
160
+ // A search that yields no usable token must return NOTHING, not the whole
161
+ // published list — the same contract listAll and listForSeller keep.
162
+ const plan = planSearchTxt(opts?.search);
163
+ if (plan.empty)
164
+ return emptySearchResult();
147
165
  let baseQuery = this.getCollection().where(BLOG_POST_FIELDS.STATUS, "==", "published");
148
166
  if (opts?.category) {
149
167
  baseQuery = baseQuery.where(BLOG_POST_FIELDS.CATEGORY, "==", opts.category);
@@ -151,11 +169,15 @@ class BlogRepository extends BaseRepository {
151
169
  if (opts?.featuredOnly) {
152
170
  baseQuery = baseQuery.where(BLOG_POST_FIELDS.IS_FEATURED, "==", true);
153
171
  }
154
- return this.sieveQuery(model, BlogRepository.SIEVE_FIELDS, {
172
+ if (plan.head) {
173
+ baseQuery = baseQuery.where(BLOG_POST_FIELDS.SEARCH_TXT, "array-contains", plan.head);
174
+ }
175
+ const result = await this.sieveQuery(model, BlogRepository.SIEVE_FIELDS, {
155
176
  baseQuery,
156
177
  defaultPageSize: 12,
157
178
  maxPageSize: 50,
158
179
  });
180
+ return refineSearchTxt(result, plan.rest);
159
181
  }
160
182
  /** Derived on every write path via `applyWriteHooks`. */
161
183
  buildSearchTxtFor(data) {
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import Link from "next/link";
4
4
  import { useQuery } from "@tanstack/react-query";
5
- import { Container, Div, Grid, Heading, Main, Row, Section, Skeleton, Span, Stack, Text } from "../../../ui";
5
+ import { Container, Div, GatedPrice, Grid, Heading, Main, Row, Section, Skeleton, Span, Stack, Text } from "../../../ui";
6
6
  import { MediaImage } from "../../media/MediaImage";
7
7
  import { apiClient } from "../../../http";
8
8
  import { ACCOUNT_ENDPOINTS } from "../../../constants/api-endpoints";
@@ -29,5 +29,5 @@ export function PublicCatalogueItemDetailView({ ownerSlug, itemId }) {
29
29
  }
30
30
  const { owner, item, linkedProductHref } = data;
31
31
  const images = item.images.length > 0 ? item.images : item.mainImage ? [item.mainImage] : [];
32
- return (_jsx(Main, { children: _jsxs(Container, { size: "lg", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", gap: "xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: "/", className: "hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: `${ROUTES.PUBLIC.PROFILE(ownerSlug)}/catalogue`, className: "hover:text-primary-600 transition-colors", children: owner.displayName ?? "Catalogue" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: item.title })] }), _jsxs(Grid, { cols: 2, gap: "lg", children: [images.length > 0 ? (_jsx(Div, { className: `relative aspect-square w-full ${__O.hidden}`, rounded: "xl", surface: "muted", children: _jsx(MediaImage, { src: images[0], alt: item.title, size: "hero", objectFit: "contain" }) })) : (_jsx(Div, { className: "relative aspect-square w-full", rounded: "xl", surface: "muted" })), images.length > 1 && (_jsx(Row, { gap: "sm", wrap: true, className: "col-span-2", children: images.slice(1).map((src, i) => (_jsx(Div, { className: `relative h-20 w-20 ${__O.hidden}`, rounded: "lg", surface: "muted", children: _jsx(MediaImage, { src: src, alt: `${item.title} photo ${i + 2}`, size: "thumbnail", objectFit: "cover" }) }, i))) })), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "bold", color: "primary", children: item.title }), typeof item.price === "number" && item.price > 0 && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: formatCurrency(item.price) }), _jsx(Text, { size: "xs", color: "muted", children: "Estimated value \u2014 not for direct sale from this page." })] })), _jsxs(Row, { gap: "sm", wrap: true, children: [item.condition && (_jsx(Span, { size: "xs", weight: "medium", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: item.condition })), typeof item.quantity === "number" && (_jsxs(Span, { size: "xs", weight: "medium", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: ["Qty: ", item.quantity] }))] }), item.description && (_jsx(Text, { color: "muted", className: "whitespace-pre-line", children: item.description })), owner.displayName && (_jsxs(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Owned by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: owner.displayName })] })), linkedProductHref && (_jsx(Link, { href: linkedProductHref, className: "inline-flex w-fit items-center rounded-lg bg-primary px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-semibold text-white transition-colors hover:opacity-90", children: "View live listing \u2192" }))] })] })] }) }));
32
+ return (_jsx(Main, { children: _jsxs(Container, { size: "lg", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", gap: "xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: "/", className: "hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: `${ROUTES.PUBLIC.PROFILE(ownerSlug)}/catalogue`, className: "hover:text-primary-600 transition-colors", children: owner.displayName ?? "Catalogue" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: item.title })] }), _jsxs(Grid, { cols: 2, gap: "lg", children: [images.length > 0 ? (_jsx(Div, { className: `relative aspect-square w-full ${__O.hidden}`, rounded: "xl", surface: "muted", children: _jsx(MediaImage, { src: images[0], alt: item.title, size: "hero", objectFit: "contain" }) })) : (_jsx(Div, { className: "relative aspect-square w-full", rounded: "xl", surface: "muted" })), images.length > 1 && (_jsx(Row, { gap: "sm", wrap: true, className: "col-span-2", children: images.slice(1).map((src, i) => (_jsx(Div, { className: `relative h-20 w-20 ${__O.hidden}`, rounded: "lg", surface: "muted", children: _jsx(MediaImage, { src: src, alt: `${item.title} photo ${i + 2}`, size: "thumbnail", objectFit: "cover" }) }, i))) })), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "bold", color: "primary", children: item.title }), typeof item.price === "number" && item.price > 0 && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { label: "Sign in to see the estimated value", children: formatCurrency(item.price) }) }), _jsx(Text, { size: "xs", color: "muted", children: "Estimated value \u2014 not for direct sale from this page." })] })), _jsxs(Row, { gap: "sm", wrap: true, children: [item.condition && (_jsx(Span, { size: "xs", weight: "medium", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: item.condition })), typeof item.quantity === "number" && (_jsxs(Span, { size: "xs", weight: "medium", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: ["Qty: ", item.quantity] }))] }), item.description && (_jsx(Text, { color: "muted", className: "whitespace-pre-line", children: item.description })), owner.displayName && (_jsxs(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Owned by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: owner.displayName })] })), linkedProductHref && (_jsx(Link, { href: linkedProductHref, className: "inline-flex w-fit items-center rounded-lg bg-primary px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-semibold text-white transition-colors hover:opacity-90", children: "View live listing \u2192" }))] })] })] }) }));
33
33
  }
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import Link from "next/link";
4
4
  import { useQuery } from "@tanstack/react-query";
5
- import { Div, Grid, Heading, Skeleton, Text } from "../../../ui";
5
+ import { Div, GatedPrice, Grid, Heading, Skeleton, Text } from "../../../ui";
6
6
  import { MediaImage } from "../../media/MediaImage";
7
7
  import { apiClient } from "../../../http";
8
8
  import { ACCOUNT_ENDPOINTS } from "../../../constants/api-endpoints";
@@ -19,5 +19,5 @@ export function PublicCatalogueView({ ownerSlug }) {
19
19
  if (items.length === 0) {
20
20
  return _jsx(Text, { variant: "secondary", children: "Nothing public here yet." });
21
21
  }
22
- return (_jsx(Grid, { cols: 4, gap: "md", children: items.map((item) => (_jsx(Link, { href: `/catalogue/${ownerSlug}/${item.id}`, className: "block transition-opacity hover:opacity-90", children: _jsxs(Div, { rounded: "xl", border: "default", padding: "md", surface: "card", children: [_jsx(MediaImage, { src: item.mainImage || item.images[0] || "", alt: item.title, size: "card" }), _jsx(Div, { padding: "t-sm", children: _jsx(Heading, { level: 4, size: "sm", children: item.title }) }), typeof item.price === "number" && item.price > 0 && (_jsx(Text, { size: "sm", color: "muted", children: formatCurrency(item.price) }))] }) }, item.id))) }));
22
+ return (_jsx(Grid, { cols: 4, gap: "md", children: items.map((item) => (_jsx(Link, { href: `/catalogue/${ownerSlug}/${item.id}`, className: "block transition-opacity hover:opacity-90", children: _jsxs(Div, { rounded: "xl", border: "default", padding: "md", surface: "card", children: [_jsx(MediaImage, { src: item.mainImage || item.images[0] || "", alt: item.title, size: "card" }), _jsx(Div, { padding: "t-sm", children: _jsx(Heading, { level: 4, size: "sm", children: item.title }) }), typeof item.price === "number" && item.price > 0 && (_jsx(Text, { size: "sm", color: "muted", children: _jsx(GatedPrice, { children: formatCurrency(item.price) }) }))] }) }, item.id))) }));
23
23
  }
@@ -10,19 +10,23 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  */
11
11
  import { useState } from "react";
12
12
  import Link from "next/link";
13
- import { Button, Div, Grid, Row, Text } from "../../../ui";
13
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
14
+ import { Button, Div, GatedPrice, Grid, Row, Text } from "../../../ui";
14
15
  import { MediaImage } from "../../media/MediaImage";
15
16
  import { ImageLightbox } from "../../../ui/components/ImageLightbox";
16
17
  import { ROUTES } from "../../../next/routing/route-map";
17
18
  import { formatCurrency } from "../../../utils/number.formatter";
18
19
  const PLACEHOLDER_EMOJI = "📦";
19
- function toGalleryImages(members) {
20
+ function toGalleryImages(members, canSeePrices) {
20
21
  return members.map((p, i) => ({
21
22
  src: p.mainImage ?? p.images?.[0] ?? "",
22
23
  alt: p.title,
23
24
  badge: `#${i + 1}`,
24
25
  caption: p.title,
25
- sub: formatCurrency(p.price ?? 0, p.currency ?? "INR"),
26
+ // `sub` is a plain string on the lightbox caption, so the gate has to
27
+ // happen here — the tile's own price is gated in the JSX below, and this
28
+ // would otherwise print it again one click away.
29
+ sub: canSeePrices ? formatCurrency(p.price ?? 0, p.currency ?? "INR") : undefined,
26
30
  }));
27
31
  }
28
32
  function makeBundleItemClickHandler(p, idx, onItemClick, setLightboxIndex) {
@@ -37,13 +41,14 @@ function makeBundleItemClickHandler(p, idx, onItemClick, setLightboxIndex) {
37
41
  }
38
42
  export function BundleCollage({ members, onItemClick }) {
39
43
  const [lightboxIndex, setLightboxIndex] = useState(null);
44
+ const { canSeePrices } = useCanSeePrices();
40
45
  if (!members.length)
41
46
  return null;
42
- const galleryImages = toGalleryImages(members);
47
+ const galleryImages = toGalleryImages(members, canSeePrices);
43
48
  return (_jsxs(_Fragment, { children: [_jsx(Grid, { cols: "categoryCards", gap: "3", children: members.map((p, idx) => {
44
49
  const cover = p.mainImage ?? p.images?.[0];
45
50
  const href = String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#");
46
- return (_jsxs(Div, { layout: "flex-col", className: "group relative overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] transition-transform hover:scale-[1.01]", rounded: "lg", children: [_jsx(Button, { variant: "ghost", type: "button", onClick: makeBundleItemClickHandler(p, idx, onItemClick, setLightboxIndex), paddingX: "none", paddingY: "none", rounded: "none", className: "relative aspect-square w-full overflow-hidden focus-visible:ring-2 focus-visible:ring-[var(--appkit-color-primary)]", "aria-label": `View ${p.title} in lightbox`, children: _jsxs(Div, { className: "relative aspect-square w-full", children: [cover ? (_jsx(MediaImage, { src: cover, alt: p.title, size: "card", className: "transition-transform group-hover:scale-105" })) : (_jsx(Row, { textSize: "3xl", surface: "muted", className: "absolute inset-0", align: "center", justify: "center", children: PLACEHOLDER_EMOJI })), _jsxs(Div, { textWeight: "semibold", textSize: "xs", surface: "overlay-xl", padding: "chip-2xs", className: "absolute left-2 top-2 text-white", rounded: "default", children: ["#", idx + 1] })] }) }), _jsxs(Div, { padding: "xs", children: [_jsx(Link, { href: href, className: "line-clamp-2 text-[length:var(--appkit-text-sm)] font-medium hover:underline", children: p.title }), _jsx(Text, { className: "mt-0.5 text-[var(--appkit-color-text-muted)]", size: "xs", children: formatCurrency(p.price ?? 0, p.currency ?? "INR") })] })] }, p.id));
51
+ return (_jsxs(Div, { layout: "flex-col", className: "group relative overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] transition-transform hover:scale-[1.01]", rounded: "lg", children: [_jsx(Button, { variant: "ghost", type: "button", onClick: makeBundleItemClickHandler(p, idx, onItemClick, setLightboxIndex), paddingX: "none", paddingY: "none", rounded: "none", className: "relative aspect-square w-full overflow-hidden focus-visible:ring-2 focus-visible:ring-[var(--appkit-color-primary)]", "aria-label": `View ${p.title} in lightbox`, children: _jsxs(Div, { className: "relative aspect-square w-full", children: [cover ? (_jsx(MediaImage, { src: cover, alt: p.title, size: "card", className: "transition-transform group-hover:scale-105" })) : (_jsx(Row, { textSize: "3xl", surface: "muted", className: "absolute inset-0", align: "center", justify: "center", children: PLACEHOLDER_EMOJI })), _jsxs(Div, { textWeight: "semibold", textSize: "xs", surface: "overlay-xl", padding: "chip-2xs", className: "absolute left-2 top-2 text-white", rounded: "default", children: ["#", idx + 1] })] }) }), _jsxs(Div, { padding: "xs", children: [_jsx(Link, { href: href, className: "line-clamp-2 text-[length:var(--appkit-text-sm)] font-medium hover:underline", children: p.title }), _jsx(Text, { className: "mt-0.5 text-[var(--appkit-color-text-muted)]", size: "xs", children: _jsx(GatedPrice, { children: formatCurrency(p.price ?? 0, p.currency ?? "INR") }) })] })] }, p.id));
47
52
  }) }), _jsx(ImageLightbox, { images: galleryImages, activeIndex: lightboxIndex, onClose: () => setLightboxIndex(null), onNavigate: (i) => setLightboxIndex(i), showThumbnails: true })] }));
48
53
  }
49
54
  export default BundleCollage;
@@ -1,5 +1,5 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Badge, Container, Div, Heading, Li, Main, Row, Section, Span, Stack, Text, Ul, } from "../../../ui";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Badge, Container, Div, GatedPrice, PricesOnly, Heading, Li, Main, Row, Section, Span, Stack, Text, Ul, } from "../../../ui";
3
3
  import { formatCurrency } from "../../../utils/number.formatter";
4
4
  import Link from "next/link";
5
5
  import { BUNDLE_COPY, BUNDLE_STOCK_VARIANT, } from "../../../_internal/shared/features/categories/bundle-copy";
@@ -25,5 +25,9 @@ export function BundleDetailView({ bundle, members = [], onBuyNow, onAddToCart,
25
25
  ? formatCurrency(bundle.bundlePrice, "INR")
26
26
  : BUNDLE_COPY.detail.priceFallback;
27
27
  const discount = computeBundleDiscount(bundle.bundlePrice, bundle.bundleOriginalTotal);
28
- return (_jsxs(Main, { children: [_jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "lg", direction: "md-row", align: "start", children: [_jsx(Div, { className: "w-full md:w-2/5", children: members.length > 0 ? (_jsx(BundleCollage, { members: members })) : (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, rounded: "2xl", surface: "subtle", children: _jsx(Div, { className: "h-full w-full", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "hero", fallback: PLACEHOLDER_EMOJI }) }) })) }), _jsxs(Stack, { gap: "md", className: "flex-1", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "semibold", color: "primary", children: bundle.name }), _jsxs(Row, { gap: "sm", align: "center", wrap: true, children: [_jsx(Text, { size: "lg", weight: "bold", children: priceLabel }), discount && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, "INR") }), _jsx(Badge, { variant: "success", children: BUNDLE_COPY.detail.discountBadge(discount.percent) })] })), _jsxs(Text, { size: "sm", color: "muted", children: ["\u00B7 ", BUNDLE_COPY.detail.itemCount(memberCount)] }), _jsx(Badge, { variant: BUNDLE_STOCK_VARIANT[stock], children: STOCK_BADGE_TEXT[stock] })] }), discount && (_jsx(Text, { size: "sm", color: "success", weight: "medium", children: BUNDLE_COPY.detail.savingsLabel(formatCurrency(discount.savings, "INR")) })), bundle.description && (_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text-muted)]", size: "sm", weight: "semibold", children: BUNDLE_COPY.detailDescriptionHeading }), _jsx(Text, { className: "whitespace-pre-line", children: bundle.description })] })), onBuyNow ? (_jsx(BundleBuyNowCta, { bundleSlug: bundle.slug, outOfStock: stock === "out_of_stock", onBuyNow: onBuyNow, onAddToCart: onAddToCart })) : (_jsxs(Div, { textSize: "sm", className: "border-dashed", border: "strong", surface: "muted", rounded: "xl", padding: "md", "aria-live": "polite", children: [_jsx(Text, { weight: "semibold", className: "mb-1 block", children: BUNDLE_COPY.detail.ctaDisabled }), _jsx(Text, { size: "sm", color: "muted", children: BUNDLE_COPY.detail.ctaHint })] }))] })] }), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: BUNDLE_COPY.detailItemsHeading }), members.length === 0 ? (_jsx(Text, { color: "muted", children: BUNDLE_COPY.detail.emptyMembers })) : (_jsx(Ul, { gap: "2", className: "grid grid-cols-1 sm:grid-cols-2", children: members.map((p, i) => (_jsxs(Li, { layout: "flex-center", gap: "2", rounded: "lg", border: "subtle", padding: "inlineSm", textSize: "sm", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "w-5", color: "faint", children: ["#", i + 1] }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#"), className: "flex-1 truncate font-medium hover:underline hover:text-primary", children: p.title }), _jsx(Span, { size: "xs", color: "muted", children: formatCurrency(p.price ?? 0, p.currency ?? "INR") })] }, p.id))) }))] })] }) }) }), relatedBundles.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Bundles" }), _jsx(CategoryGrid, { categories: relatedBundles, getHref: (c) => String(ROUTES.PUBLIC.BUNDLE_DETAIL(c.slug)) })] }) }))] }));
28
+ return (_jsxs(Main, { children: [_jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "lg", direction: "md-row", align: "start", children: [_jsx(Div, { className: "w-full md:w-2/5", children: members.length > 0 ? (_jsx(BundleCollage, { members: members })) : (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, rounded: "2xl", surface: "subtle", children: _jsx(Div, { className: "h-full w-full", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "hero", fallback: PLACEHOLDER_EMOJI }) }) })) }), _jsxs(Stack, { gap: "md", className: "flex-1", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "semibold", color: "primary", children: bundle.name }), _jsxs(Row, { gap: "sm", align: "center", wrap: true, children: [_jsx(Text, { size: "lg", weight: "bold", children: _jsx(GatedPrice, { label: "Sign in to see the bundle price", children: priceLabel }) }), discount && (
29
+ // Both the struck-through total and the "N% off" badge are
30
+ // derived from the bundle price, so they disclose it even
31
+ // though the badge carries no currency symbol.
32
+ _jsxs(PricesOnly, { children: [_jsx(Text, { size: "sm", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, "INR") }), _jsx(Badge, { variant: "success", children: BUNDLE_COPY.detail.discountBadge(discount.percent) })] })), _jsxs(Text, { size: "sm", color: "muted", children: ["\u00B7 ", BUNDLE_COPY.detail.itemCount(memberCount)] }), _jsx(Badge, { variant: BUNDLE_STOCK_VARIANT[stock], children: STOCK_BADGE_TEXT[stock] })] }), discount && (_jsx(PricesOnly, { children: _jsx(Text, { size: "sm", color: "success", weight: "medium", children: BUNDLE_COPY.detail.savingsLabel(formatCurrency(discount.savings, "INR")) }) })), bundle.description && (_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text-muted)]", size: "sm", weight: "semibold", children: BUNDLE_COPY.detailDescriptionHeading }), _jsx(Text, { className: "whitespace-pre-line", children: bundle.description })] })), onBuyNow ? (_jsx(BundleBuyNowCta, { bundleSlug: bundle.slug, outOfStock: stock === "out_of_stock", onBuyNow: onBuyNow, onAddToCart: onAddToCart })) : (_jsxs(Div, { textSize: "sm", className: "border-dashed", border: "strong", surface: "muted", rounded: "xl", padding: "md", "aria-live": "polite", children: [_jsx(Text, { weight: "semibold", className: "mb-1 block", children: BUNDLE_COPY.detail.ctaDisabled }), _jsx(Text, { size: "sm", color: "muted", children: BUNDLE_COPY.detail.ctaHint })] }))] })] }), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: BUNDLE_COPY.detailItemsHeading }), members.length === 0 ? (_jsx(Text, { color: "muted", children: BUNDLE_COPY.detail.emptyMembers })) : (_jsx(Ul, { gap: "2", className: "grid grid-cols-1 sm:grid-cols-2", children: members.map((p, i) => (_jsxs(Li, { layout: "flex-center", gap: "2", rounded: "lg", border: "subtle", padding: "inlineSm", textSize: "sm", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "w-5", color: "faint", children: ["#", i + 1] }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#"), className: "flex-1 truncate font-medium hover:underline hover:text-primary", children: p.title }), _jsx(Span, { size: "xs", color: "muted", children: _jsx(GatedPrice, { children: formatCurrency(p.price ?? 0, p.currency ?? "INR") }) })] }, p.id))) }))] })] }) }) }), relatedBundles.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Bundles" }), _jsx(CategoryGrid, { categories: relatedBundles, getHref: (c) => String(ROUTES.PUBLIC.BUNDLE_DETAIL(c.slug)) })] }) }))] }));
29
33
  }
@@ -155,5 +155,5 @@ export async function CategoryDetailPageView({ slug }) {
155
155
  const hasCover = Boolean(coverImage);
156
156
  return (_jsxs(Main, { children: [_jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORIES), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Categories" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: category?.name ?? slug })] }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), childCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "inline", children: _jsx(Div, { layout: "flex", gap: "2", className: `.5 ${__O.xAuto} [scrollbar-width:none]`, padding: "b-2xs", children: childCategories.map((child) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(child.slug)),
157
157
  // audit-responsive-wrap-ok: deliberate horizontal-scroll chip row (parent has overflow-x-auto) — each chip staying single-line is the intended design, not hidden content
158
- className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1-5)] rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] px-[var(--appkit-space-4)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary transition-colors whitespace-nowrap", children: [child.display?.icon && (_jsx(Span, { className: "leading-none", children: child.display.icon })), child.name, (child.metrics?.productCount ?? 0) > 0 && (_jsx(Span, { size: "xs", color: "muted", children: (child.metrics?.productCount ?? 0).toLocaleString() }))] }, child.id))) }) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
158
+ className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1-5)] rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] px-[var(--appkit-space-4)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary transition-colors whitespace-nowrap", children: [child.display?.icon && (_jsx(Span, { className: "leading-none", children: child.display.icon })), child.name, (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0) > 0 && (_jsx(Span, { size: "xs", color: "muted", children: (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0).toLocaleString() }))] }, child.id))) }) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
159
159
  }