@mohasinac/appkit 4.37.1 → 4.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/server/features/bundles/og.d.ts +0 -1
- package/dist/_internal/server/features/bundles/og.js +2 -18
- package/dist/_internal/server/features/catalogue/og.d.ts +0 -1
- package/dist/_internal/server/features/catalogue/og.js +1 -9
- package/dist/_internal/server/features/classified/og.d.ts +0 -1
- package/dist/_internal/server/features/classified/og.js +1 -9
- package/dist/_internal/server/features/digital-code/og.d.ts +0 -1
- package/dist/_internal/server/features/digital-code/og.js +1 -9
- package/dist/_internal/server/features/grouped/og.d.ts +7 -4
- package/dist/_internal/server/features/grouped/og.js +7 -6
- package/dist/_internal/server/features/item-requests/og.d.ts +0 -1
- package/dist/_internal/server/features/item-requests/og.js +2 -18
- package/dist/_internal/server/features/live/og.d.ts +0 -1
- package/dist/_internal/server/features/live/og.js +1 -9
- package/dist/_internal/server/features/prize-draws/og.js +5 -10
- package/dist/_internal/server/features/products/og.js +5 -2
- package/dist/_internal/server/features/seo/sitemap.js +10 -2
- package/dist/_internal/server/features/shared/listing-adapters.d.ts +1 -2
- package/dist/_internal/server/features/shared/listing-adapters.js +5 -3
- package/dist/_internal/shared/listing-types/_registry.d.ts +31 -4
- package/dist/_internal/shared/listing-types/art/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/art/config.js +2 -0
- package/dist/_internal/shared/listing-types/auction/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/auction/config.js +3 -0
- package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/classified/config.js +3 -0
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/digital-code/config.js +3 -0
- package/dist/_internal/shared/listing-types/live/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/live/config.js +3 -0
- package/dist/_internal/shared/listing-types/pre-order/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/pre-order/config.js +2 -0
- package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/prize-draw/config.js +3 -0
- package/dist/_internal/shared/listing-types/standard/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/standard/config.js +2 -0
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/stickers/config.js +2 -0
- package/dist/client/observability/reportClientError.js +11 -1
- package/dist/client.d.ts +4 -0
- package/dist/client.js +4 -0
- package/dist/core/hooks/useSyncManager.js +11 -1
- package/dist/features/account/hooks/useNotifications.js +25 -5
- package/dist/features/auctions/components/AuctionBottomActions.js +10 -2
- package/dist/features/auctions/components/AuctionDetailPageView.js +12 -3
- package/dist/features/auctions/components/LiveBidPrice.js +2 -2
- package/dist/features/auctions/components/LiveMinIncrement.d.ts +1 -1
- package/dist/features/auctions/components/LiveMinIncrement.js +8 -0
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -3
- package/dist/features/auctions/components/PlaceBidFormClient.js +16 -1
- package/dist/features/auctions/hooks/useAuctions.js +12 -1
- package/dist/features/auctions/hooks/useRealtimeBids.d.ts +3 -0
- package/dist/features/auctions/hooks/useRealtimeBids.js +138 -36
- package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +2 -2
- package/dist/features/catalogue/components/PublicCatalogueView.js +2 -2
- package/dist/features/categories/components/BundleCollage.js +10 -5
- package/dist/features/categories/components/BundleDetailView.js +7 -3
- package/dist/features/categories/components/CategoryProductsListing.js +4 -1
- package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -2
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -2
- package/dist/features/homepage/components/FeaturedBundlesSection.js +4 -4
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -2
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -3
- package/dist/features/pre-orders/components/PreOrderActionsClient.js +2 -2
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -2
- package/dist/features/pre-orders/components/PreOrdersIndexListing.js +4 -1
- package/dist/features/products/actions/product-actions.d.ts +9 -0
- package/dist/features/products/actions/product-actions.js +11 -0
- package/dist/features/products/columns/productTableColumns.js +2 -2
- package/dist/features/products/components/AuctionsIndexListing.js +4 -1
- package/dist/features/products/components/BidHistory.js +2 -2
- package/dist/features/products/components/CompareOverlay.js +2 -2
- package/dist/features/products/components/GroupMemberPicker.js +5 -5
- package/dist/features/products/components/ListingBottomActions.js +12 -3
- package/dist/features/products/components/MakeOfferButton.js +7 -1
- package/dist/features/products/components/MarketplaceBundleCard.js +4 -4
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -2
- package/dist/features/products/components/PrizeDrawCollage.js +16 -5
- package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
- package/dist/features/products/components/PrizeDrawsIndexListing.js +4 -1
- package/dist/features/products/components/PrizeRevealModal.js +2 -2
- package/dist/features/products/components/ProductDetailActions.js +10 -3
- package/dist/features/products/components/ProductFilters.js +2 -2
- package/dist/features/products/components/ProductGrid.js +5 -2
- package/dist/features/products/components/ProductsIndexListing.js +12 -1
- package/dist/features/products/components/ProductsIndexPageView.js +26 -0
- package/dist/features/products/components/ShowGroupSection.js +2 -2
- package/dist/features/products/components/SublistingCarouselSection.js +2 -2
- package/dist/features/products/constants/listing-tabs.d.ts +36 -12
- package/dist/features/products/constants/listing-tabs.js +38 -14
- package/dist/features/products/constants/sieve.d.ts +14 -0
- package/dist/features/products/constants/sieve.js +39 -0
- package/dist/features/tester/seed-data/authored/buying__bidding.js +5 -4
- package/dist/features/tester/seed-data/authored/buying__browsing-search.js +85 -85
- package/dist/features/tester/seed-data/authored/buying__image-tile-layout.js +2 -2
- package/dist/features/tester/seed-data/authored/buying__product-detail.js +53 -15
- package/dist/features/tester/seed-data/authored/content-discovery__search.js +6 -4
- package/dist/features/tester/seed-data/authored/design-ux__carousel-arrow-bounds.js +4 -4
- package/dist/features/tester/seed-data/authored/design-ux__homepage-carousels.js +2 -2
- package/dist/features/tester/seed-data/authored/design-ux__status-badge-legibility.js +4 -4
- package/dist/features/tester/seed-data/authored/design-ux__sticky-cta-bar.js +8 -8
- package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +8 -8
- package/dist/features/tester/seed-data/authored/seo__canonical-and-host.js +16 -0
- package/dist/features/tester/seed-data/authored/seo__og-images.js +17 -0
- package/dist/features/tester/seed-data/stores-tester-seed-data.js +41 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +36 -6
- package/dist/features/wishlist/components/WishlistPage.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/react/contexts/SessionContext.js +40 -1
- package/dist/react/hooks/useCanSeePrices.d.ts +37 -0
- package/dist/react/hooks/useCanSeePrices.js +40 -0
- package/dist/seed/addresses-seed-data.js +36 -2
- package/dist/seed/users-seed-data.js +10 -2
- package/dist/seo/json-ld.d.ts +21 -0
- package/dist/seo/json-ld.js +45 -0
- package/dist/styles.css +41 -0
- package/dist/ui/components/GatedPrice.d.ts +66 -0
- package/dist/ui/components/GatedPrice.js +80 -0
- package/dist/ui/components/GatedPrice.style.css +40 -0
- package/dist/ui/components/PriceDisplay.d.ts +12 -0
- package/dist/ui/components/PriceDisplay.js +17 -17
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 [
|
|
23
|
-
|
|
145
|
+
const [state, setState] = useState({
|
|
146
|
+
data: null,
|
|
147
|
+
connected: false,
|
|
148
|
+
});
|
|
24
149
|
useEffect(() => {
|
|
25
150
|
if (!productId || typeof EventSource === "undefined") {
|
|
26
|
-
|
|
151
|
+
setState({ data: null, connected: false });
|
|
27
152
|
return;
|
|
28
153
|
}
|
|
29
|
-
const
|
|
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
|
-
|
|
57
|
-
|
|
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
|
}
|