@mohasinac/appkit 4.37.0 → 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 (128) 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 +3 -1
  109. package/dist/index.js +6 -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/index.d.ts +1 -1
  115. package/dist/seed/index.js +6 -1
  116. package/dist/seed/users-seed-data.js +10 -2
  117. package/dist/seo/json-ld.d.ts +21 -0
  118. package/dist/seo/json-ld.js +45 -0
  119. package/dist/styles.css +41 -0
  120. package/dist/ui/components/GatedPrice.d.ts +66 -0
  121. package/dist/ui/components/GatedPrice.js +80 -0
  122. package/dist/ui/components/GatedPrice.style.css +40 -0
  123. package/dist/ui/components/PriceDisplay.d.ts +12 -0
  124. package/dist/ui/components/PriceDisplay.js +17 -17
  125. package/dist/ui/components/index.style.css +1 -0
  126. package/dist/ui/index.d.ts +2 -0
  127. package/dist/ui/index.js +1 -0
  128. package/package.json +1 -1
@@ -2,7 +2,17 @@
2
2
  import { normalizeError } from "../../errors/normalize";
3
3
  import { CLIENT_ERROR_ENDPOINTS } from "../../constants/api-endpoints";
4
4
  const SEEN = new Map();
5
- const DEDUPE_WINDOW_MS = 5000;
5
+ /**
6
+ * How long an identical error is suppressed before it is reported again.
7
+ *
8
+ * This is a rate limit on a beacon that costs a function invocation per send,
9
+ * so the window has to be sized against the worst case rather than the typical
10
+ * one: a component stuck in a render loop re-throws the SAME error forever, and
11
+ * at the original 5s that was 12 invocations/minute/tab, indefinitely, from a
12
+ * single broken page. The first occurrence is still reported immediately —
13
+ * widening this loses no distinct error, only repeats of one already recorded.
14
+ */
15
+ const DEDUPE_WINDOW_MS = 60000;
6
16
  const MAX_SEEN = 32;
7
17
  function topFrame(stack) {
8
18
  if (!stack)
package/dist/client.d.ts CHANGED
@@ -61,6 +61,10 @@ export { SideModal } from "./ui/components/SideModal";
61
61
  export { UnsavedChangesModal } from "./ui/components/UnsavedChangesModal";
62
62
  export { LoginRequiredModal } from "./ui/components/LoginRequiredModal";
63
63
  export type { LoginRequiredModalProps } from "./ui/components/LoginRequiredModal";
64
+ export { GatedPrice, PricesOnly, GATED_PRICE_CLASS } from "./ui/components/GatedPrice";
65
+ export type { GatedPriceProps } from "./ui/components/GatedPrice";
66
+ export { useCanSeePrices } from "./react/hooks/useCanSeePrices";
67
+ export type { CanSeePricesState } from "./react/hooks/useCanSeePrices";
64
68
  export { isAuthError } from "./utils/auth-error";
65
69
  export { useToast } from "./ui/components/Toast";
66
70
  export { StickyToolbar } from "./ui/components/StickyToolbar";
package/dist/client.js CHANGED
@@ -89,6 +89,10 @@ export { UnsavedChangesModal } from "./ui/components/UnsavedChangesModal";
89
89
  // [CLIENT-ONLY]-Cannot run in SSR mode — uses browser-only APIs (window.location).
90
90
  // LoginRequiredModal - Modal prompting unauthenticated users to log in.
91
91
  export { LoginRequiredModal } from "./ui/components/LoginRequiredModal";
92
+ // GatedPrice - a money amount only signed-in visitors may read. GATED_PRICE_CLASS
93
+ // is a contract with the JSON-LD paywall markup; see the component.
94
+ export { GatedPrice, PricesOnly, GATED_PRICE_CLASS } from "./ui/components/GatedPrice";
95
+ export { useCanSeePrices } from "./react/hooks/useCanSeePrices";
92
96
  // isAuthError - Detects auth/authorization errors from server actions or fetch responses.
93
97
  export { isAuthError } from "./utils/auth-error";
94
98
  // [CLIENT-ONLY]-Cannot run in SSR mode â€" uses browser-only APIs (window, navigator, localStorage, matchMedia, DOM events) that do not exist in Node.js.
@@ -18,7 +18,17 @@ import { useQueryClient } from "@tanstack/react-query";
18
18
  import { apiClient } from "../../http";
19
19
  import { CART_ENDPOINTS, ACCOUNT_ENDPOINTS } from "../../constants/api-endpoints";
20
20
  import { getCartOps, clearCartOps, getWishlistOps, clearWishlistOps, } from "../../features/cart/utils/pending-ops";
21
- const SYNC_INTERVAL_MS = 30000;
21
+ /**
22
+ * Cadence for replaying queued cart/wishlist ops.
23
+ *
24
+ * A tick with no pending ops is nearly free, but a tick WITH ops also
25
+ * invalidates the cart and wishlist queries, so each one can cascade into
26
+ * refetches. At 30s that ran for every signed-in tab for as long as it stayed
27
+ * open. 2 minutes is ample for an offline-replay backstop — the queue is also
28
+ * flushed eagerly on `visibilitychange` and `pagehide` (see below), which is
29
+ * what actually catches the tab-closing case that matters.
30
+ */
31
+ const SYNC_INTERVAL_MS = 120000;
22
32
  async function replayCartOps() {
23
33
  const ops = getCartOps();
24
34
  if (ops.length === 0)
@@ -1,6 +1,11 @@
1
1
  import { useMutation, useQuery } from "@tanstack/react-query";
2
2
  import { apiClient } from "../../../http";
3
3
  import { NOTIFICATIONS_ENDPOINTS } from "../../../constants/api-endpoints";
4
+ /**
5
+ * Poll cadence for the notification badge. See the comment at the
6
+ * `refetchInterval` below for why this is minutes rather than seconds.
7
+ */
8
+ const NOTIFICATIONS_POLL_MS = 5 * 60000;
4
9
  export function useNotifications(limit = 10, options) {
5
10
  const notificationsEndpoint = options?.notificationsEndpoint ?? NOTIFICATIONS_ENDPOINTS.LIST;
6
11
  const readAllEndpoint = options?.readAllEndpoint ?? NOTIFICATIONS_ENDPOINTS.READ_ALL;
@@ -8,12 +13,27 @@ export function useNotifications(limit = 10, options) {
8
13
  const { data, isLoading, refetch } = useQuery({
9
14
  queryKey: ["notifications", "list", String(limit)],
10
15
  queryFn: () => apiClient.get(`${notificationsEndpoint}?limit=${limit}`),
11
- staleTime: 30000,
16
+ staleTime: NOTIFICATIONS_POLL_MS,
12
17
  // Notifications originate server-side (order/bid/moderation events) with
13
- // no client-driven local-first path to short-circuit, unlike cart/
14
- // wishlist. Poll on the same ~30s cadence as useSyncManager so the bell
15
- // badge picks up new notifications without requiring a window refocus.
16
- refetchInterval: 30000,
18
+ // no client-driven local-first path to short-circuit, unlike cart/wishlist,
19
+ // so the badge does need a poll rather than a local-first read.
20
+ //
21
+ // 🛑 This hook is mounted in TitleBar, i.e. on EVERY page of the site, so
22
+ // its interval is multiplied by every open tab of every signed-in user for
23
+ // as long as those tabs exist — it is wall-clock, not activity-gated, so a
24
+ // tab left open overnight keeps firing. At the original 30s that was 120
25
+ // invocations/hour/tab for an unread COUNT, and it was a material share of
26
+ // the Vercel function-invocation spend that paused the account (2026-09-14).
27
+ //
28
+ // 5 minutes + refetch-on-focus is the trade: a user who is actually looking
29
+ // at the tab gets a fresh badge the moment they return to it, and an
30
+ // abandoned tab costs 12 invocations/hour instead of 120.
31
+ refetchInterval: NOTIFICATIONS_POLL_MS,
32
+ // The global QueryClient default is `refetchOnWindowFocus: false`
33
+ // (src/app/[locale]/QueryProvider.tsx). Opt back in HERE specifically,
34
+ // because it is what makes the longer interval acceptable rather than
35
+ // simply slower.
36
+ refetchOnWindowFocus: true,
17
37
  });
18
38
  const { mutate: markRead } = useMutation({
19
39
  mutationFn: (id) => apiClient.patch(readEndpoint(id), {}),
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { useBottomActions } from "../../layout/hooks/useBottomActions";
5
+ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
5
6
  import { formatCurrency } from "../../../utils/number.formatter";
6
7
  import { ACTION_ID, ACTION_META } from "../../products/constants/action-defs";
7
8
  import { useCountdown } from "../../../react";
@@ -40,6 +41,7 @@ export function AuctionBottomActions(props) {
40
41
  const { currentBid, currency, bidCount, isEnded, auctionEndDate, buyNowPrice } = props;
41
42
  const [open, setOpen] = useState(false);
42
43
  const remaining = useCountdown(auctionEndDate ?? undefined);
44
+ const { canSeePrices } = useCanSeePrices();
43
45
  const placeBidMeta = ACTION_META[ACTION_ID.PLACE_BID];
44
46
  const buyNowMeta = ACTION_META[ACTION_ID.BUY_NOW_AUCTION];
45
47
  // Same single predicate the form and the card use. `!!props.onBuyNow` matters
@@ -54,14 +56,20 @@ export function AuctionBottomActions(props) {
54
56
  ? [
55
57
  {
56
58
  ...buyNowMeta,
57
- label: `Buy Now — ${formatCurrency(buyNowPrice, currency)}`,
59
+ // Both of these are plain strings on the bottom bar rather
60
+ // than slots, so the gate happens as they are built.
61
+ label: canSeePrices
62
+ ? `Buy Now — ${formatCurrency(buyNowPrice, currency)}`
63
+ : "Buy Now",
58
64
  onClick: () => setOpen(true),
59
65
  },
60
66
  ]
61
67
  : []),
62
68
  ],
63
69
  secondaryLabel: formatCountdownLabel(remaining),
64
- infoLabel: `${formatCurrency(currentBid, currency)} · ${bidCount} bid${bidCount !== 1 ? "s" : ""}`,
70
+ infoLabel: canSeePrices
71
+ ? `${formatCurrency(currentBid, currency)} · ${bidCount} bid${bidCount !== 1 ? "s" : ""}`
72
+ : `Sign in to see the bid · ${bidCount} bid${bidCount !== 1 ? "s" : ""}`,
65
73
  desktop: "after-scroll",
66
74
  });
67
75
  return (_jsx(Modal, { isOpen: open, onClose: () => setOpen(false), size: "md", title: "Place your bid", children: _jsx(PlaceBidFormClient, { ...props }) }));
@@ -17,7 +17,7 @@ import { normalizeRichTextHtml } from "../../../utils/string.formatter";
17
17
  import { safeDisplayName } from "../../../security";
18
18
  import { getSiteSettingsGlobal } from "../../admin/utils/getSiteSettingsGlobal";
19
19
  import { isBuyNowAvailable, resolveMinBid, resolveMinBidIncrement, } from "../../../_internal/shared/features/auctions/config";
20
- import { Button, Container, CountdownDisplay, Div, Heading, Input, Main, Nav, RichText, Row, Section, Span, Stack, Text, Ul, Li, Dl, Dt, Dd, } from "../../../ui";
20
+ import { Button, Container, CountdownDisplay, Div, GatedPrice, PricesOnly, Heading, Input, Main, Nav, RichText, Row, Section, Span, Stack, Text, Ul, Li, Dl, Dt, Dd, } from "../../../ui";
21
21
  import { AuctionDetailView } from "../../products/components/AuctionDetailView";
22
22
  import { ProductTabsShell } from "../../products/components/ProductTabsShell";
23
23
  import { CustomSectionTabContent } from "../../products/components/CustomSectionTabContent";
@@ -48,7 +48,7 @@ function toDescriptionHtml(raw) {
48
48
  }
49
49
  function renderAuctionInfoPanel(props) {
50
50
  const { productId, title, currentBid, currency, bidCount, isEnded, endDate, buyNowPrice, buyNowAvailable, featured, freeShipping, condition, finalSale, category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref } = props;
51
- return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_BADGE, children: "\uD83C\uDFF7\uFE0F Live Auction" }), isEnded ? (_jsx(Span, { color: "error", surface: "danger-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Ended" })) : (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Active" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-0.5", color: "muted", size: "xs", children: "Current bid" }), _jsx(LiveBidPrice, { productId: productId, currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "2xl", badgeSize: "sm" }), endDate && !isEnded && (_jsxs(Text, { className: "mt-1.5", color: "muted", size: "sm", children: ["Ends in ", _jsx(Span, { weight: "medium", color: "muted", children: _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" }) })] })), endDate && _jsxs(Text, { className: "mt-0.5", color: "faint", size: "xs", children: [isEnded ? "Ended" : "Ends", " ", endDate.toLocaleString()] })] }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Row, { align: "center", gap: "sm", className: "border border-[var(--appkit-color-primary-200)] dark:border-[var(--appkit-color-primary-800)] bg-primary-50 dark:bg-primary-900/20", padding: "inlineSm", rounded: "lg", children: [_jsx(Span, { size: "xs", color: "muted", children: "Buy Now:" }), _jsx(Span, { size: "base", weight: "bold", className: "text-primary-700 dark:text-primary-300", children: formatCurrency(buyNowPrice, currency) }), _jsx(Span, { size: "xs", color: "faint", children: "\u2014 in \u201CPlace a bid\u201D" })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, finalSale: finalSale, labels: { featured: "Featured", fasterDelivery: "Faster Delivery", ratedSeller: "Rated Seller", condition: "Condition", conditionNew: "New", conditionUsed: "Used", conditionBroken: "For Parts", conditionRefurbished: "Refurbished", returnable: "Returnable", finalSale: "Final Sale", freeShipping: "Free Shipping", codAvailable: "Cash on Delivery", emiAvailable: "EMI Available", wishlistCount: (n) => `${n} wishlisted`, categoryProductCount: (n, cat) => `${n} in ${cat}` } }), (categoryName || category || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [category && _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryName || category }), !category && categoryName && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: categoryName }), brand && brandSlug && _jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand }), brand && !brandSlug && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand })] })), productFeatures && features.length > 0 && _jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures }), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this item" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => _jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i)) })] })), descriptionHtml && _jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" }), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Listed by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && _jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" })] }) }))] }));
51
+ return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_BADGE, children: "\uD83C\uDFF7\uFE0F Live Auction" }), isEnded ? (_jsx(Span, { color: "error", surface: "danger-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Ended" })) : (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Active" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-0.5", color: "muted", size: "xs", children: "Current bid" }), _jsx(LiveBidPrice, { productId: productId, currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "2xl", badgeSize: "sm" }), endDate && !isEnded && (_jsxs(Text, { className: "mt-1.5", color: "muted", size: "sm", children: ["Ends in ", _jsx(Span, { weight: "medium", color: "muted", children: _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" }) })] })), endDate && _jsxs(Text, { className: "mt-0.5", color: "faint", size: "xs", children: [isEnded ? "Ended" : "Ends", " ", endDate.toLocaleString()] })] }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Row, { align: "center", gap: "sm", className: "border border-[var(--appkit-color-primary-200)] dark:border-[var(--appkit-color-primary-800)] bg-primary-50 dark:bg-primary-900/20", padding: "inlineSm", rounded: "lg", children: [_jsx(Span, { size: "xs", color: "muted", children: "Buy Now:" }), _jsx(Span, { size: "base", weight: "bold", className: "text-primary-700 dark:text-primary-300", children: _jsx(GatedPrice, { label: "Sign in to see the Buy Now price", children: formatCurrency(buyNowPrice, currency) }) }), _jsx(Span, { size: "xs", color: "faint", children: "\u2014 in \u201CPlace a bid\u201D" })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, finalSale: finalSale, labels: { featured: "Featured", fasterDelivery: "Faster Delivery", ratedSeller: "Rated Seller", condition: "Condition", conditionNew: "New", conditionUsed: "Used", conditionBroken: "For Parts", conditionRefurbished: "Refurbished", returnable: "Returnable", finalSale: "Final Sale", freeShipping: "Free Shipping", codAvailable: "Cash on Delivery", emiAvailable: "EMI Available", wishlistCount: (n) => `${n} wishlisted`, categoryProductCount: (n, cat) => `${n} in ${cat}` } }), (categoryName || category || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [category && _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryName || category }), !category && categoryName && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: categoryName }), brand && brandSlug && _jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand }), brand && !brandSlug && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand })] })), productFeatures && features.length > 0 && _jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures }), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this item" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => _jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i)) })] })), descriptionHtml && _jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" }), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Listed by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && _jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" })] }) }))] }));
52
52
  }
53
53
  /**
54
54
  * Store reviews for the auction's seller.
@@ -218,7 +218,16 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
218
218
  storeName: typeof p.storeName === "string" ? p.storeName : undefined,
219
219
  } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.AUCTIONS), className: CLS_BREADCRUMB_LINK, children: "Auctions" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(AuctionDetailView, { renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title })), renderInfo: () => renderAuctionInfoPanel({ productId: String(product.id), title, currentBid, currency, bidCount, isEnded, endDate, buyNowPrice, buyNowAvailable, featured, freeShipping, condition, finalSale: isFinalSale(p), category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref }), renderBidForm: () => onPlaceBid ? (_jsx(Stack, { id: "auction-bid-form", gap: "3", children: _jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "3", rounded: "xl", surface: "muted", children: [_jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(LiveBidPrice, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "xl", badgeSize: "xs" }), !isEnded && (_jsx(LiveMinIncrement, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride }))] }), !isEnded && endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(PlaceBidModalButton, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow, triggerClassName: "w-full" })] }) })) : (
220
220
  /* Read-only bid panel — shown when no bid action is wired (preview/demo) */
221
- _jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsx(Stack, { gap: "xs", children: _jsxs(Text, { size: "xs", color: "muted", children: ["Starting bid: ", formatCurrency(startingBid, currency), !isEnded && _jsxs(_Fragment, { children: [" \u00B7 min increment ", formatCurrency(initialMinBidIncrement, currency)] })] }) }), _jsxs(Stack, { gap: "sm", children: [_jsx(Input, { type: "number", placeholder: `At least ${formatCurrency(initialMinBid, currency)}`, min: initialMinBid, "aria-label": "Your bid amount", disabled: isEnded }), !isEnded && endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isEnded, children: isEnded ? "Auction Ended" : "Place Bid" }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Buy Now available at ", formatCurrency(buyNowPrice, currency)] }))] }), tags.length > 0 && (_jsx(Div, { border: "default", className: "border-t", padding: "t-md", children: _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: tag }, tag))) }) }))] })), renderMobileBidForm: () => !isEnded && onPlaceBid ? (_jsxs(Stack, { className: `lg:hidden ${__P.p4}`, border: "subtle", gap: "3", rounded: "xl", surface: "muted", children: [_jsx(LiveBidPrice, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "base", badgeSize: "xs" }), endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(PlaceBidModalButton, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow, triggerClassName: "w-full" })] })) : !isEnded ? (_jsxs(Div, { className: `${__P.p4} lg:hidden`, border: "subtle", rounded: "xl", surface: "muted", children: [_jsxs(Row, { align: "center", gap: "sm", className: "mb-3", children: [_jsx(Span, { size: "base", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsxs(Span, { size: "xs", color: "muted", children: [bidCount, " bids"] })] }), endDate && (_jsxs(Text, { align: "center", className: "mb-3", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", children: "Place Bid" })] })) : null, renderSublistingSection: sublistingCategoryId
221
+ _jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsx(Stack, { gap: "xs", children: _jsx(Text, { size: "xs", color: "muted", children: _jsxs(GatedPrice, { label: "Sign in to see the starting bid", children: ["Starting bid: ", formatCurrency(startingBid, currency), !isEnded && _jsxs(_Fragment, { children: [" \u00B7 min increment ", formatCurrency(initialMinBidIncrement, currency)] })] }) }) }), _jsxs(Stack, { gap: "sm", children: [_jsx(Input, { type: "number",
222
+ // The minimum is stated in the gated line above rather than
223
+ // in the placeholder: a placeholder is a plain string prop,
224
+ // so it cannot be gated in place and would print the bid
225
+ // floor to signed-out visitors.
226
+ placeholder: "Enter your bid", min: initialMinBid, "aria-label": "Your bid amount", disabled: isEnded }), !isEnded && endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isEnded, children: isEnded ? "Auction Ended" : "Place Bid" }), buyNowAvailable && buyNowPrice !== null && (
227
+ // Hidden rather than prompted: the Buy Now chip at the top
228
+ // of the panel already carries the gate, so repeating the
229
+ // prompt here would be the same affordance twice.
230
+ _jsx(PricesOnly, { children: _jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Buy Now available at ", formatCurrency(buyNowPrice, currency)] }) }))] }), tags.length > 0 && (_jsx(Div, { border: "default", className: "border-t", padding: "t-md", children: _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: tag }, tag))) }) }))] })), renderMobileBidForm: () => !isEnded && onPlaceBid ? (_jsxs(Stack, { className: `lg:hidden ${__P.p4}`, border: "subtle", gap: "3", rounded: "xl", surface: "muted", children: [_jsx(LiveBidPrice, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "base", badgeSize: "xs" }), endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(PlaceBidModalButton, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow, triggerClassName: "w-full" })] })) : !isEnded ? (_jsxs(Div, { className: `${__P.p4} lg:hidden`, border: "subtle", rounded: "xl", surface: "muted", children: [_jsxs(Row, { align: "center", gap: "sm", className: "mb-3", children: [_jsx(Span, { size: "base", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: _jsx(GatedPrice, { label: "Sign in to see the current bid", children: formatCurrency(currentBid, currency) }) }), _jsxs(Span, { size: "xs", color: "muted", children: [bidCount, " bids"] })] }), endDate && (_jsxs(Text, { align: "center", className: "mb-3", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", children: "Place Bid" })] })) : null, renderSublistingSection: sublistingCategoryId
222
231
  ? () => (_jsx(SublistingCarouselSection, { sublistingCategoryId: sublistingCategoryId, currentListingId: String(product.id) }))
223
232
  : undefined, renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden", children: specs.map((s, i) => (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-36 flex-shrink-0", color: "primary", weight: "medium", children: s.name }), _jsxs(Dd, { className: "flex-1", color: "muted", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, customTabs: customSections.map((s) => ({
224
233
  id: s.id,
@@ -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
  }