@mohasinac/appkit 4.37.1 → 4.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +20 -1
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +21 -1
- package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts +40 -0
- package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.js +67 -0
- 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/checkout/actions.js +18 -0
- 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/download.d.ts +60 -0
- package/dist/_internal/server/features/digital-code/download.js +128 -0
- 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/digital-code/pool.d.ts +101 -0
- package/dist/_internal/server/features/digital-code/pool.js +208 -0
- package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
- package/dist/_internal/server/features/digital-code/upload.js +144 -0
- 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/server/jobs/core/cleanupRtdbEvents.js +40 -0
- package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
- package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
- package/dist/_internal/server/notifications/send-recorder.js +126 -0
- 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 +6 -0
- package/dist/client.js +10 -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/blog/components/BlogIndexPageView.js +10 -1
- package/dist/features/blog/repository/blog.repository.d.ts +14 -0
- package/dist/features/blog/repository/blog.repository.js +23 -1
- 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/CategoryDetailPageView.js +1 -1
- package/dist/features/categories/components/CategoryProductsListing.js +4 -1
- package/dist/features/categories/components/CategoryTree.js +1 -1
- package/dist/features/categories/repository/categories.repository.d.ts +34 -2
- package/dist/features/categories/repository/categories.repository.js +31 -9
- package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -2
- package/dist/features/contact/email.js +36 -1
- 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/orders/repository/orders.repository.d.ts +21 -1
- package/dist/features/orders/repository/orders.repository.js +26 -9
- 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/products/repository/products.repository.d.ts +35 -0
- package/dist/features/products/repository/products.repository.js +116 -3
- package/dist/features/products/schemas/firestore.d.ts +37 -0
- package/dist/features/seller/actions/seller-actions.js +4 -13
- package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
- package/dist/features/seller/components/SellerProductShell.js +11 -2
- package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
- package/dist/features/stores/api/[storeSlug]/reviews/route.js +89 -30
- package/dist/features/stores/components/StoresIndexPageView.js +17 -1
- package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
- 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__offers.js +1 -1
- package/dist/features/tester/seed-data/authored/buying__product-detail.js +53 -15
- package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
- 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/tester/utils/phases.js +78 -12
- package/dist/features/wishlist/components/WishlistPage.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
- package/dist/providers/db-firebase/rtdb-paths.js +7 -0
- 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/server.d.ts +3 -0
- package/dist/server.js +12 -0
- package/dist/styles.css +41 -0
- package/dist/ui/components/FilePickerButton.d.ts +32 -0
- package/dist/ui/components/FilePickerButton.js +23 -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 +4 -0
- package/dist/ui/index.js +8 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Row, Span } from "../../../ui";
|
|
3
|
+
import { GatedPrice, Row, Span } from "../../../ui";
|
|
4
4
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
5
5
|
import { useLiveAuctionBid } from "../hooks/useLiveAuctionBid";
|
|
6
6
|
/**
|
|
@@ -13,5 +13,5 @@ import { useLiveAuctionBid } from "../hooks/useLiveAuctionBid";
|
|
|
13
13
|
*/
|
|
14
14
|
export function LiveBidPrice({ productId, currentBid, bidCount, currency, isEnded, priceSize = "xl", badgeSize = "xs", }) {
|
|
15
15
|
const live = useLiveAuctionBid(productId, currentBid, bidCount, { enabled: !isEnded });
|
|
16
|
-
return (_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { weight: "bold", className: "text-primary-600 dark:text-primary-400", size: priceSize, children: formatCurrency(live.currentBid, currency) }), _jsxs(Span, { size: badgeSize, weight: "medium", rounded: "full", padding: "pill-md", surface: "subtle", color: "muted", children: [live.bidCount, " ", live.bidCount === 1 ? "bid" : "bids"] })] }));
|
|
16
|
+
return (_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { weight: "bold", className: "text-primary-600 dark:text-primary-400", size: priceSize, children: _jsx(GatedPrice, { label: "Sign in to see the current bid", children: formatCurrency(live.currentBid, currency) }) }), _jsxs(Span, { size: badgeSize, weight: "medium", rounded: "full", padding: "pill-md", surface: "subtle", color: "muted", children: [live.bidCount, " ", live.bidCount === 1 ? "bid" : "bids"] })] }));
|
|
17
17
|
}
|
|
@@ -15,4 +15,4 @@ export interface LiveMinIncrementProps {
|
|
|
15
15
|
* increment depends on the current bid amount (tiered), so it must keep
|
|
16
16
|
* resolving against the live current bid, not just the SSR snapshot.
|
|
17
17
|
*/
|
|
18
|
-
export declare function LiveMinIncrement({ productId, currentBid, bidCount, currency, isEnded, tiers, minBidIncrementOverride, size, }: LiveMinIncrementProps): import("react").JSX.Element;
|
|
18
|
+
export declare function LiveMinIncrement({ productId, currentBid, bidCount, currency, isEnded, tiers, minBidIncrementOverride, size, }: LiveMinIncrementProps): import("react").JSX.Element | null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Span } from "../../../ui";
|
|
4
|
+
import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
|
|
4
5
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
5
6
|
import { useLiveAuctionBid } from "../hooks/useLiveAuctionBid";
|
|
6
7
|
import { resolveMinBidIncrement, } from "../../../_internal/shared/features/auctions/config";
|
|
@@ -13,5 +14,12 @@ import { resolveMinBidIncrement, } from "../../../_internal/shared/features/auct
|
|
|
13
14
|
export function LiveMinIncrement({ productId, currentBid, bidCount, currency, isEnded, tiers, minBidIncrementOverride, size = "xs", }) {
|
|
14
15
|
const live = useLiveAuctionBid(productId, currentBid, bidCount, { enabled: !isEnded });
|
|
15
16
|
const increment = resolveMinBidIncrement(live.currentBid, tiers, minBidIncrementOverride);
|
|
17
|
+
const { canSeePrices } = useCanSeePrices();
|
|
18
|
+
// The increment is derived from the current bid, so it discloses the bid's
|
|
19
|
+
// magnitude. This is a SECONDARY hint next to the gated bid itself, so it
|
|
20
|
+
// hides outright — "min increment Sign in to see price" reads as broken copy,
|
|
21
|
+
// and the prompt is already on the bid one line up.
|
|
22
|
+
if (!canSeePrices)
|
|
23
|
+
return null;
|
|
16
24
|
return (_jsxs(Span, { size: size, color: "muted", children: ["min increment ", formatCurrency(increment, currency)] }));
|
|
17
25
|
}
|
|
@@ -12,7 +12,7 @@ import { useCountdown } from "../../../react";
|
|
|
12
12
|
import { ROUTES } from "../../../next";
|
|
13
13
|
import { formatCurrency } from "../../../utils";
|
|
14
14
|
import { getDefaultCurrency } from "../../../core/baseline-resolver";
|
|
15
|
-
import { BaseListingCard, Button, Caption, Div, Row, Span, Stack, Text, TextLink } from "../../../ui";
|
|
15
|
+
import { BaseListingCard, Button, Caption, Div, GatedPrice, Row, Span, Stack, Text, TextLink } from "../../../ui";
|
|
16
16
|
import { isBuyNowAvailable } from "../../../_internal/shared/features/auctions/config";
|
|
17
17
|
import { CardStoreLine } from "../../products/components/CardStoreLine";
|
|
18
18
|
const CLS_STAR_ICON = "h-5 w-5 fill-warning text-warning";
|
|
@@ -25,11 +25,11 @@ function renderAuctionCardHero(props) {
|
|
|
25
25
|
}
|
|
26
26
|
function renderAuctionCardInfoList(props) {
|
|
27
27
|
const { product, auctionHref, displayBid, bidCount, isEnded, remaining, inWishlist, wishlistLoading, wishlistActions, countdownClass, mergedLabels, handleWishlist, handleNavigate } = props;
|
|
28
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Row, { className: "min-w-0", align: "start", justify: "between", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: `flex-1 leading-snug transition-colors hover:text-primary`, color: "primary", truncate: 2, size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Text, { className: "text-primary", size: "sm", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), _jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", className: `items-[center] py-[0.125rem] text-[11px] ${countdownClass}`, padding: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-2.5 w-2.5" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), bidCount > 0 && _jsx(Caption, { className: "text-[11px]", children: mergedLabels.totalBids(bidCount) })] }), !isEnded && (_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", className: "self-start px-[0.625rem] mt-0.5", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }))] }));
|
|
28
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Row, { className: "min-w-0", align: "start", justify: "between", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: `flex-1 leading-snug transition-colors hover:text-primary`, color: "primary", truncate: 2, size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Text, { className: "text-primary", size: "sm", weight: "bold", children: _jsx(GatedPrice, { label: "Sign in to see the bid", children: formatCurrency(displayBid, getDefaultCurrency()) }) }), _jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", className: `items-[center] py-[0.125rem] text-[11px] ${countdownClass}`, padding: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-2.5 w-2.5" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), bidCount > 0 && _jsx(Caption, { className: "text-[11px]", children: mergedLabels.totalBids(bidCount) })] }), !isEnded && (_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", className: "self-start px-[0.625rem] mt-0.5", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }))] }));
|
|
29
29
|
}
|
|
30
30
|
function renderAuctionCardInfoGrid(props) {
|
|
31
31
|
const { product, auctionHref, displayBid, bidCount, hasCurrentBid, isEnded, remaining, resolvedBuyoutPrice, inWishlist, wishlistLoading, wishlistActions, countdownClass, mergedLabels, handleWishlist, handleNavigate } = props;
|
|
32
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "min-w-0 flex-1 leading-snug transition-colors hover:text-primary", color: "primary", size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Div, { children: [_jsx(Caption, { children: isEnded && hasCurrentBid ? mergedLabels.winningBid : hasCurrentBid ? mergedLabels.currentBid : mergedLabels.startingBid }), _jsx(Text, { className: "leading-none text-primary", size: "base", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), isEnded && product.winnerDisplayName && (_jsxs(Caption, { className: "mt-0.5", color: "muted", children: [mergedLabels.wonBy, ": ", maskDisplayName(product.winnerDisplayName)] }))] }), _jsxs(Row, { wrap: true, justify: "between", gap: "sm", className: "gap-x-2 gap-y-1", children: [_jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", textSize: "xs", className: `items-[center] ${countdownClass}`, paddingY: "y-2xs", paddingX: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-3 w-3" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), _jsx(Caption, { children: mergedLabels.totalBids(bidCount) })] }), _jsx(Row, { wrap: true, gap: "xs", className: "mt-auto", children: isEnded ? (_jsx(Button, { gap: "xs", type: "button", variant: "ghost", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1 cursor-not-allowed opacity-60", disabled: true, children: _jsx(Span, { children: mergedLabels.ended }) })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }), resolvedBuyoutPrice ? (
|
|
32
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "min-w-0 flex-1 leading-snug transition-colors hover:text-primary", color: "primary", size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsx(CardStoreLine, { storeName: product.storeName, storeId: product.storeId }), _jsxs(Div, { children: [_jsx(Caption, { children: isEnded && hasCurrentBid ? mergedLabels.winningBid : hasCurrentBid ? mergedLabels.currentBid : mergedLabels.startingBid }), _jsx(Text, { className: "leading-none text-primary", size: "base", weight: "bold", children: _jsx(GatedPrice, { label: "Sign in to see the bid", children: formatCurrency(displayBid, getDefaultCurrency()) }) }), isEnded && product.winnerDisplayName && (_jsxs(Caption, { className: "mt-0.5", color: "muted", children: [mergedLabels.wonBy, ": ", maskDisplayName(product.winnerDisplayName)] }))] }), _jsxs(Row, { wrap: true, justify: "between", gap: "sm", className: "gap-x-2 gap-y-1", children: [_jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", textSize: "xs", className: `items-[center] ${countdownClass}`, paddingY: "y-2xs", paddingX: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-3 w-3" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), _jsx(Caption, { children: mergedLabels.totalBids(bidCount) })] }), _jsx(Row, { wrap: true, gap: "xs", className: "mt-auto", children: isEnded ? (_jsx(Button, { gap: "xs", type: "button", variant: "ghost", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1 cursor-not-allowed opacity-60", disabled: true, children: _jsx(Span, { children: mergedLabels.ended }) })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }), resolvedBuyoutPrice ? (
|
|
33
33
|
/* `secondary`, not `danger` — this button only navigates to the
|
|
34
34
|
PDP, and `danger` is reserved for destructive actions (Rule #7
|
|
35
35
|
kinds). ACTIONS.AUCTION["buy-it-now"] is itself `secondary`. */
|
|
@@ -5,7 +5,9 @@ import { useRouter } from "next/navigation";
|
|
|
5
5
|
import { useTranslations } from "next-intl";
|
|
6
6
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
7
7
|
import { isAuthError } from "../../../utils/auth-error";
|
|
8
|
-
import { Button, CountdownDisplay, Div, LoginRequiredModal, Modal, Row, Span, Stack, Text } from "../../../ui";
|
|
8
|
+
import { Button, CountdownDisplay, Div, LoginRequiredModal, Modal, Row, Span, Stack, Text, TextLink } from "../../../ui";
|
|
9
|
+
import { ROUTES } from "../../../next/routing/route-map";
|
|
10
|
+
import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
|
|
9
11
|
import { Form } from "../../../ui/components/Form";
|
|
10
12
|
import { FieldInput } from "../../../ui/forms/FieldInput";
|
|
11
13
|
import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
|
|
@@ -36,6 +38,10 @@ const BID_FALLBACK = "Failed to place bid. Please try again.";
|
|
|
36
38
|
const BUY_NOW_FALLBACK = "Buy Now failed. Please try again.";
|
|
37
39
|
export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, startingBid, tiers, minBidIncrementOverride, currency, isEnded, auctionEndDate, buyNowPrice, bidCount: ssrBidCount, tags = [], onPlaceBid, onBuyNow, }) {
|
|
38
40
|
const router = useRouter();
|
|
41
|
+
// Declared with the other hooks, never inside the early return below — a
|
|
42
|
+
// conditional hook is a render-order violation, and this component early-
|
|
43
|
+
// returns for signed-out viewers.
|
|
44
|
+
const { canSeePrices } = useCanSeePrices();
|
|
39
45
|
// Live-updates while the bid card is open — reflects other bidders'
|
|
40
46
|
// activity on this auction, not just the bidder's own submission.
|
|
41
47
|
const live = useLiveAuctionBid(productId, ssrCurrentBid, ssrBidCount, { enabled: !isEnded });
|
|
@@ -219,6 +225,15 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
|
|
|
219
225
|
}
|
|
220
226
|
});
|
|
221
227
|
}
|
|
228
|
+
// A signed-out visitor cannot place a bid — `useAuthGate` already stops the
|
|
229
|
+
// submit — and this panel is the single densest money surface on the site:
|
|
230
|
+
// current bid, starting bid, minimum, increment, the +N preset chips and the
|
|
231
|
+
// Buy Now price, twelve amounts in all. Replacing the whole panel is both the
|
|
232
|
+
// honest affordance (the form was never usable) and the only version that
|
|
233
|
+
// cannot leak one of the twelve through a string prop.
|
|
234
|
+
if (!canSeePrices) {
|
|
235
|
+
return (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "sm", rounded: "xl", surface: "muted", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Bidding is for members" }), _jsx(Text, { size: "xs", color: "muted", children: "Sign in to see the current bid and place your own." }), _jsx(TextLink, { href: String(ROUTES.AUTH.LOGIN), weight: "semibold", size: "sm", children: "Sign in to bid" })] }));
|
|
236
|
+
}
|
|
222
237
|
return (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Current bid" : "Starting bid" }), _jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Starting bid" : "Minimum bid" })] }), _jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(Span, { size: "xl", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsx(Span, { size: "sm", color: "muted", children: formatCurrency(hasBids ? startingBid : minBid, currency) })] }), _jsxs(Text, { size: "xs", color: "faint", children: [bidCount, " ", bidCount === 1 ? "bid" : "bids", " \u00B7 min increment", " ", formatCurrency(minBidIncrement, currency)] })] }), _jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to place a bid. Please log in or create an account to continue." }), _jsx(Form, { schema: schema, onSubmit: (e) => {
|
|
223
238
|
// handler wired through render-prop below — keeps setFieldError in scope
|
|
224
239
|
e.preventDefault();
|
|
@@ -57,7 +57,18 @@ export function useAuctionBids(auctionSlug, limit = 10, opts) {
|
|
|
57
57
|
queryKey: ["auction-bids", auctionSlug, limit],
|
|
58
58
|
queryFn: () => apiClient.get(`${AUCTION_ENDPOINTS.BIDS(auctionSlug)}?limit=${limit}`),
|
|
59
59
|
enabled: (opts?.enabled ?? true) && !!auctionSlug,
|
|
60
|
-
|
|
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
|
}
|
|
@@ -14,7 +14,16 @@ export async function BlogIndexPageView({ searchParams = {} }) {
|
|
|
14
14
|
const page = Number(sp(searchParams, "page")) || 1;
|
|
15
15
|
const pageSize = Number(sp(searchParams, "pageSize")) || 24;
|
|
16
16
|
const category = sp(searchParams, "category");
|
|
17
|
-
|
|
17
|
+
/*
|
|
18
|
+
* 🛑 `q` MUST be read and forwarded. Without it /blog?q=anything rendered the
|
|
19
|
+
* FULL published list — the same failure the API route's own header records
|
|
20
|
+
* having already fixed on its side, never back-ported here (Root Cause #59).
|
|
21
|
+
* It cannot self-correct on the client either: this result becomes
|
|
22
|
+
* `initialData`, and the listing hook sets `staleTime: Infinity` when given
|
|
23
|
+
* SSR data, so the unfiltered first paint is frozen for that query key.
|
|
24
|
+
*/
|
|
25
|
+
const q = sp(searchParams, "q").trim();
|
|
26
|
+
const sieveResult = await safeRead(() => blogRepository.listPublished({ ...(category ? { category } : {}), ...(q ? { search: q } : {}) }, { sorts: sort, page, pageSize }), { route: "/blog", key: "blogPosts.listPublished", fallback: null });
|
|
18
27
|
// Sandbox posts are public rows by design — not on someone else's first paint.
|
|
19
28
|
const publicSieve = sieveResult
|
|
20
29
|
? { ...sieveResult, items: hidePublicTestData(sieveResult.items) }
|
|
@@ -83,9 +83,23 @@ declare class BlogRepository extends BaseRepository<BlogPostDocument> {
|
|
|
83
83
|
parseValue: typeof parseSieveDateValue;
|
|
84
84
|
};
|
|
85
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* `search` uses the same `searchTxt` push-down as `listAll` — head token as an
|
|
88
|
+
* `array-contains` clause, remaining tokens AND-refined in memory.
|
|
89
|
+
*
|
|
90
|
+
* 🛑 IT HAD NO SEARCH PARAMETER AT ALL, and that is why /blog?q= was inert.
|
|
91
|
+
* `blogGET` searches via `listAll(model, { search })`, but the SSR view calls
|
|
92
|
+
* THIS method, so the server-rendered list ignored `q` entirely — and because
|
|
93
|
+
* that result is handed to the client as `initialData` under
|
|
94
|
+
* `staleTime: Infinity`, the unfiltered first paint was frozen and never
|
|
95
|
+
* refetched. Root Cause #59: the fix went into the API route and was never
|
|
96
|
+
* back-ported to the SSR view that duplicates its job. The route's own header
|
|
97
|
+
* documents the API-side half of this exact bug.
|
|
98
|
+
*/
|
|
86
99
|
listPublished(opts: {
|
|
87
100
|
category?: BlogPostCategory;
|
|
88
101
|
featuredOnly?: boolean;
|
|
102
|
+
search?: string;
|
|
89
103
|
}, model: SieveModel): Promise<FirebaseSieveResult<BlogPostDocument>>;
|
|
90
104
|
/** Derived on every write path via `applyWriteHooks`. */
|
|
91
105
|
protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
|
|
@@ -143,7 +143,25 @@ class BlogRepository extends BaseRepository {
|
|
|
143
143
|
throw new DatabaseError(`Failed to find posts by author: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* `search` uses the same `searchTxt` push-down as `listAll` — head token as an
|
|
148
|
+
* `array-contains` clause, remaining tokens AND-refined in memory.
|
|
149
|
+
*
|
|
150
|
+
* 🛑 IT HAD NO SEARCH PARAMETER AT ALL, and that is why /blog?q= was inert.
|
|
151
|
+
* `blogGET` searches via `listAll(model, { search })`, but the SSR view calls
|
|
152
|
+
* THIS method, so the server-rendered list ignored `q` entirely — and because
|
|
153
|
+
* that result is handed to the client as `initialData` under
|
|
154
|
+
* `staleTime: Infinity`, the unfiltered first paint was frozen and never
|
|
155
|
+
* refetched. Root Cause #59: the fix went into the API route and was never
|
|
156
|
+
* back-ported to the SSR view that duplicates its job. The route's own header
|
|
157
|
+
* documents the API-side half of this exact bug.
|
|
158
|
+
*/
|
|
146
159
|
async listPublished(opts, model) {
|
|
160
|
+
// A search that yields no usable token must return NOTHING, not the whole
|
|
161
|
+
// published list — the same contract listAll and listForSeller keep.
|
|
162
|
+
const plan = planSearchTxt(opts?.search);
|
|
163
|
+
if (plan.empty)
|
|
164
|
+
return emptySearchResult();
|
|
147
165
|
let baseQuery = this.getCollection().where(BLOG_POST_FIELDS.STATUS, "==", "published");
|
|
148
166
|
if (opts?.category) {
|
|
149
167
|
baseQuery = baseQuery.where(BLOG_POST_FIELDS.CATEGORY, "==", opts.category);
|
|
@@ -151,11 +169,15 @@ class BlogRepository extends BaseRepository {
|
|
|
151
169
|
if (opts?.featuredOnly) {
|
|
152
170
|
baseQuery = baseQuery.where(BLOG_POST_FIELDS.IS_FEATURED, "==", true);
|
|
153
171
|
}
|
|
154
|
-
|
|
172
|
+
if (plan.head) {
|
|
173
|
+
baseQuery = baseQuery.where(BLOG_POST_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
174
|
+
}
|
|
175
|
+
const result = await this.sieveQuery(model, BlogRepository.SIEVE_FIELDS, {
|
|
155
176
|
baseQuery,
|
|
156
177
|
defaultPageSize: 12,
|
|
157
178
|
maxPageSize: 50,
|
|
158
179
|
});
|
|
180
|
+
return refineSearchTxt(result, plan.rest);
|
|
159
181
|
}
|
|
160
182
|
/** Derived on every write path via `applyWriteHooks`. */
|
|
161
183
|
buildSearchTxtFor(data) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useQuery } from "@tanstack/react-query";
|
|
5
|
-
import { Container, Div, Grid, Heading, Main, Row, Section, Skeleton, Span, Stack, Text } from "../../../ui";
|
|
5
|
+
import { Container, Div, GatedPrice, Grid, Heading, Main, Row, Section, Skeleton, Span, Stack, Text } from "../../../ui";
|
|
6
6
|
import { MediaImage } from "../../media/MediaImage";
|
|
7
7
|
import { apiClient } from "../../../http";
|
|
8
8
|
import { ACCOUNT_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
@@ -29,5 +29,5 @@ export function PublicCatalogueItemDetailView({ ownerSlug, itemId }) {
|
|
|
29
29
|
}
|
|
30
30
|
const { owner, item, linkedProductHref } = data;
|
|
31
31
|
const images = item.images.length > 0 ? item.images : item.mainImage ? [item.mainImage] : [];
|
|
32
|
-
return (_jsx(Main, { children: _jsxs(Container, { size: "lg", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", gap: "xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: "/", className: "hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: `${ROUTES.PUBLIC.PROFILE(ownerSlug)}/catalogue`, className: "hover:text-primary-600 transition-colors", children: owner.displayName ?? "Catalogue" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: item.title })] }), _jsxs(Grid, { cols: 2, gap: "lg", children: [images.length > 0 ? (_jsx(Div, { className: `relative aspect-square w-full ${__O.hidden}`, rounded: "xl", surface: "muted", children: _jsx(MediaImage, { src: images[0], alt: item.title, size: "hero", objectFit: "contain" }) })) : (_jsx(Div, { className: "relative aspect-square w-full", rounded: "xl", surface: "muted" })), images.length > 1 && (_jsx(Row, { gap: "sm", wrap: true, className: "col-span-2", children: images.slice(1).map((src, i) => (_jsx(Div, { className: `relative h-20 w-20 ${__O.hidden}`, rounded: "lg", surface: "muted", children: _jsx(MediaImage, { src: src, alt: `${item.title} photo ${i + 2}`, size: "thumbnail", objectFit: "cover" }) }, i))) })), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "bold", color: "primary", children: item.title }), typeof item.price === "number" && item.price > 0 && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: formatCurrency(item.price) }), _jsx(Text, { size: "xs", color: "muted", children: "Estimated value \u2014 not for direct sale from this page." })] })), _jsxs(Row, { gap: "sm", wrap: true, children: [item.condition && (_jsx(Span, { size: "xs", weight: "medium", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: item.condition })), typeof item.quantity === "number" && (_jsxs(Span, { size: "xs", weight: "medium", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: ["Qty: ", item.quantity] }))] }), item.description && (_jsx(Text, { color: "muted", className: "whitespace-pre-line", children: item.description })), owner.displayName && (_jsxs(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Owned by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: owner.displayName })] })), linkedProductHref && (_jsx(Link, { href: linkedProductHref, className: "inline-flex w-fit items-center rounded-lg bg-primary px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-semibold text-white transition-colors hover:opacity-90", children: "View live listing \u2192" }))] })] })] }) }));
|
|
32
|
+
return (_jsx(Main, { children: _jsxs(Container, { size: "lg", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", gap: "xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: "/", className: "hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: `${ROUTES.PUBLIC.PROFILE(ownerSlug)}/catalogue`, className: "hover:text-primary-600 transition-colors", children: owner.displayName ?? "Catalogue" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: item.title })] }), _jsxs(Grid, { cols: 2, gap: "lg", children: [images.length > 0 ? (_jsx(Div, { className: `relative aspect-square w-full ${__O.hidden}`, rounded: "xl", surface: "muted", children: _jsx(MediaImage, { src: images[0], alt: item.title, size: "hero", objectFit: "contain" }) })) : (_jsx(Div, { className: "relative aspect-square w-full", rounded: "xl", surface: "muted" })), images.length > 1 && (_jsx(Row, { gap: "sm", wrap: true, className: "col-span-2", children: images.slice(1).map((src, i) => (_jsx(Div, { className: `relative h-20 w-20 ${__O.hidden}`, rounded: "lg", surface: "muted", children: _jsx(MediaImage, { src: src, alt: `${item.title} photo ${i + 2}`, size: "thumbnail", objectFit: "cover" }) }, i))) })), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 1, size: "2xl", weight: "bold", color: "primary", children: item.title }), typeof item.price === "number" && item.price > 0 && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { label: "Sign in to see the estimated value", children: formatCurrency(item.price) }) }), _jsx(Text, { size: "xs", color: "muted", children: "Estimated value \u2014 not for direct sale from this page." })] })), _jsxs(Row, { gap: "sm", wrap: true, children: [item.condition && (_jsx(Span, { size: "xs", weight: "medium", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: item.condition })), typeof item.quantity === "number" && (_jsxs(Span, { size: "xs", weight: "medium", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", children: ["Qty: ", item.quantity] }))] }), item.description && (_jsx(Text, { color: "muted", className: "whitespace-pre-line", children: item.description })), owner.displayName && (_jsxs(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Owned by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: owner.displayName })] })), linkedProductHref && (_jsx(Link, { href: linkedProductHref, className: "inline-flex w-fit items-center rounded-lg bg-primary px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-semibold text-white transition-colors hover:opacity-90", children: "View live listing \u2192" }))] })] })] }) }));
|
|
33
33
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useQuery } from "@tanstack/react-query";
|
|
5
|
-
import { Div, Grid, Heading, Skeleton, Text } from "../../../ui";
|
|
5
|
+
import { Div, GatedPrice, Grid, Heading, Skeleton, Text } from "../../../ui";
|
|
6
6
|
import { MediaImage } from "../../media/MediaImage";
|
|
7
7
|
import { apiClient } from "../../../http";
|
|
8
8
|
import { ACCOUNT_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
@@ -19,5 +19,5 @@ export function PublicCatalogueView({ ownerSlug }) {
|
|
|
19
19
|
if (items.length === 0) {
|
|
20
20
|
return _jsx(Text, { variant: "secondary", children: "Nothing public here yet." });
|
|
21
21
|
}
|
|
22
|
-
return (_jsx(Grid, { cols: 4, gap: "md", children: items.map((item) => (_jsx(Link, { href: `/catalogue/${ownerSlug}/${item.id}`, className: "block transition-opacity hover:opacity-90", children: _jsxs(Div, { rounded: "xl", border: "default", padding: "md", surface: "card", children: [_jsx(MediaImage, { src: item.mainImage || item.images[0] || "", alt: item.title, size: "card" }), _jsx(Div, { padding: "t-sm", children: _jsx(Heading, { level: 4, size: "sm", children: item.title }) }), typeof item.price === "number" && item.price > 0 && (_jsx(Text, { size: "sm", color: "muted", children: formatCurrency(item.price) }))] }) }, item.id))) }));
|
|
22
|
+
return (_jsx(Grid, { cols: 4, gap: "md", children: items.map((item) => (_jsx(Link, { href: `/catalogue/${ownerSlug}/${item.id}`, className: "block transition-opacity hover:opacity-90", children: _jsxs(Div, { rounded: "xl", border: "default", padding: "md", surface: "card", children: [_jsx(MediaImage, { src: item.mainImage || item.images[0] || "", alt: item.title, size: "card" }), _jsx(Div, { padding: "t-sm", children: _jsx(Heading, { level: 4, size: "sm", children: item.title }) }), typeof item.price === "number" && item.price > 0 && (_jsx(Text, { size: "sm", color: "muted", children: _jsx(GatedPrice, { children: formatCurrency(item.price) }) }))] }) }, item.id))) }));
|
|
23
23
|
}
|
|
@@ -10,19 +10,23 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
10
10
|
*/
|
|
11
11
|
import { useState } from "react";
|
|
12
12
|
import Link from "next/link";
|
|
13
|
-
import {
|
|
13
|
+
import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
|
|
14
|
+
import { Button, Div, GatedPrice, Grid, Row, Text } from "../../../ui";
|
|
14
15
|
import { MediaImage } from "../../media/MediaImage";
|
|
15
16
|
import { ImageLightbox } from "../../../ui/components/ImageLightbox";
|
|
16
17
|
import { ROUTES } from "../../../next/routing/route-map";
|
|
17
18
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
18
19
|
const PLACEHOLDER_EMOJI = "📦";
|
|
19
|
-
function toGalleryImages(members) {
|
|
20
|
+
function toGalleryImages(members, canSeePrices) {
|
|
20
21
|
return members.map((p, i) => ({
|
|
21
22
|
src: p.mainImage ?? p.images?.[0] ?? "",
|
|
22
23
|
alt: p.title,
|
|
23
24
|
badge: `#${i + 1}`,
|
|
24
25
|
caption: p.title,
|
|
25
|
-
sub
|
|
26
|
+
// `sub` is a plain string on the lightbox caption, so the gate has to
|
|
27
|
+
// happen here — the tile's own price is gated in the JSX below, and this
|
|
28
|
+
// would otherwise print it again one click away.
|
|
29
|
+
sub: canSeePrices ? formatCurrency(p.price ?? 0, p.currency ?? "INR") : undefined,
|
|
26
30
|
}));
|
|
27
31
|
}
|
|
28
32
|
function makeBundleItemClickHandler(p, idx, onItemClick, setLightboxIndex) {
|
|
@@ -37,13 +41,14 @@ function makeBundleItemClickHandler(p, idx, onItemClick, setLightboxIndex) {
|
|
|
37
41
|
}
|
|
38
42
|
export function BundleCollage({ members, onItemClick }) {
|
|
39
43
|
const [lightboxIndex, setLightboxIndex] = useState(null);
|
|
44
|
+
const { canSeePrices } = useCanSeePrices();
|
|
40
45
|
if (!members.length)
|
|
41
46
|
return null;
|
|
42
|
-
const galleryImages = toGalleryImages(members);
|
|
47
|
+
const galleryImages = toGalleryImages(members, canSeePrices);
|
|
43
48
|
return (_jsxs(_Fragment, { children: [_jsx(Grid, { cols: "categoryCards", gap: "3", children: members.map((p, idx) => {
|
|
44
49
|
const cover = p.mainImage ?? p.images?.[0];
|
|
45
50
|
const href = String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#");
|
|
46
|
-
return (_jsxs(Div, { layout: "flex-col", className: "group relative overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] transition-transform hover:scale-[1.01]", rounded: "lg", children: [_jsx(Button, { variant: "ghost", type: "button", onClick: makeBundleItemClickHandler(p, idx, onItemClick, setLightboxIndex), paddingX: "none", paddingY: "none", rounded: "none", className: "relative aspect-square w-full overflow-hidden focus-visible:ring-2 focus-visible:ring-[var(--appkit-color-primary)]", "aria-label": `View ${p.title} in lightbox`, children: _jsxs(Div, { className: "relative aspect-square w-full", children: [cover ? (_jsx(MediaImage, { src: cover, alt: p.title, size: "card", className: "transition-transform group-hover:scale-105" })) : (_jsx(Row, { textSize: "3xl", surface: "muted", className: "absolute inset-0", align: "center", justify: "center", children: PLACEHOLDER_EMOJI })), _jsxs(Div, { textWeight: "semibold", textSize: "xs", surface: "overlay-xl", padding: "chip-2xs", className: "absolute left-2 top-2 text-white", rounded: "default", children: ["#", idx + 1] })] }) }), _jsxs(Div, { padding: "xs", children: [_jsx(Link, { href: href, className: "line-clamp-2 text-[length:var(--appkit-text-sm)] font-medium hover:underline", children: p.title }), _jsx(Text, { className: "mt-0.5 text-[var(--appkit-color-text-muted)]", size: "xs", children: formatCurrency(p.price ?? 0, p.currency ?? "INR") })] })] }, p.id));
|
|
51
|
+
return (_jsxs(Div, { layout: "flex-col", className: "group relative overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] transition-transform hover:scale-[1.01]", rounded: "lg", children: [_jsx(Button, { variant: "ghost", type: "button", onClick: makeBundleItemClickHandler(p, idx, onItemClick, setLightboxIndex), paddingX: "none", paddingY: "none", rounded: "none", className: "relative aspect-square w-full overflow-hidden focus-visible:ring-2 focus-visible:ring-[var(--appkit-color-primary)]", "aria-label": `View ${p.title} in lightbox`, children: _jsxs(Div, { className: "relative aspect-square w-full", children: [cover ? (_jsx(MediaImage, { src: cover, alt: p.title, size: "card", className: "transition-transform group-hover:scale-105" })) : (_jsx(Row, { textSize: "3xl", surface: "muted", className: "absolute inset-0", align: "center", justify: "center", children: PLACEHOLDER_EMOJI })), _jsxs(Div, { textWeight: "semibold", textSize: "xs", surface: "overlay-xl", padding: "chip-2xs", className: "absolute left-2 top-2 text-white", rounded: "default", children: ["#", idx + 1] })] }) }), _jsxs(Div, { padding: "xs", children: [_jsx(Link, { href: href, className: "line-clamp-2 text-[length:var(--appkit-text-sm)] font-medium hover:underline", children: p.title }), _jsx(Text, { className: "mt-0.5 text-[var(--appkit-color-text-muted)]", size: "xs", children: _jsx(GatedPrice, { children: formatCurrency(p.price ?? 0, p.currency ?? "INR") }) })] })] }, p.id));
|
|
47
52
|
}) }), _jsx(ImageLightbox, { images: galleryImages, activeIndex: lightboxIndex, onClose: () => setLightboxIndex(null), onNavigate: (i) => setLightboxIndex(i), showThumbnails: true })] }));
|
|
48
53
|
}
|
|
49
54
|
export default BundleCollage;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
2
|
-
import { Badge, Container, Div, Heading, Li, Main, Row, Section, Span, Stack, Text, Ul, } from "../../../ui";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Badge, Container, Div, GatedPrice, PricesOnly, Heading, Li, Main, Row, Section, Span, Stack, Text, Ul, } from "../../../ui";
|
|
3
3
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import { BUNDLE_COPY, BUNDLE_STOCK_VARIANT, } from "../../../_internal/shared/features/categories/bundle-copy";
|
|
@@ -25,5 +25,9 @@ export function BundleDetailView({ bundle, members = [], onBuyNow, onAddToCart,
|
|
|
25
25
|
? formatCurrency(bundle.bundlePrice, "INR")
|
|
26
26
|
: BUNDLE_COPY.detail.priceFallback;
|
|
27
27
|
const discount = computeBundleDiscount(bundle.bundlePrice, bundle.bundleOriginalTotal);
|
|
28
|
-
return (_jsxs(Main, { children: [_jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "lg", direction: "md-row", align: "start", children: [_jsx(Div, { className: "w-full md:w-2/5", children: members.length > 0 ? (_jsx(BundleCollage, { members: members })) : (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, rounded: "2xl", surface: "subtle", children: _jsx(Div, { className: "h-full w-full", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "hero", fallback: PLACEHOLDER_EMOJI }) }) })) }), _jsxs(Stack, { gap: "md", className: "flex-1", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "semibold", color: "primary", children: bundle.name }), _jsxs(Row, { gap: "sm", align: "center", wrap: true, children: [_jsx(Text, { size: "lg", weight: "bold", children:
|
|
28
|
+
return (_jsxs(Main, { children: [_jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "lg", direction: "md-row", align: "start", children: [_jsx(Div, { className: "w-full md:w-2/5", children: members.length > 0 ? (_jsx(BundleCollage, { members: members })) : (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, rounded: "2xl", surface: "subtle", children: _jsx(Div, { className: "h-full w-full", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "hero", fallback: PLACEHOLDER_EMOJI }) }) })) }), _jsxs(Stack, { gap: "md", className: "flex-1", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "semibold", color: "primary", children: bundle.name }), _jsxs(Row, { gap: "sm", align: "center", wrap: true, children: [_jsx(Text, { size: "lg", weight: "bold", children: _jsx(GatedPrice, { label: "Sign in to see the bundle price", children: priceLabel }) }), discount && (
|
|
29
|
+
// Both the struck-through total and the "N% off" badge are
|
|
30
|
+
// derived from the bundle price, so they disclose it even
|
|
31
|
+
// though the badge carries no currency symbol.
|
|
32
|
+
_jsxs(PricesOnly, { children: [_jsx(Text, { size: "sm", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, "INR") }), _jsx(Badge, { variant: "success", children: BUNDLE_COPY.detail.discountBadge(discount.percent) })] })), _jsxs(Text, { size: "sm", color: "muted", children: ["\u00B7 ", BUNDLE_COPY.detail.itemCount(memberCount)] }), _jsx(Badge, { variant: BUNDLE_STOCK_VARIANT[stock], children: STOCK_BADGE_TEXT[stock] })] }), discount && (_jsx(PricesOnly, { children: _jsx(Text, { size: "sm", color: "success", weight: "medium", children: BUNDLE_COPY.detail.savingsLabel(formatCurrency(discount.savings, "INR")) }) })), bundle.description && (_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text-muted)]", size: "sm", weight: "semibold", children: BUNDLE_COPY.detailDescriptionHeading }), _jsx(Text, { className: "whitespace-pre-line", children: bundle.description })] })), onBuyNow ? (_jsx(BundleBuyNowCta, { bundleSlug: bundle.slug, outOfStock: stock === "out_of_stock", onBuyNow: onBuyNow, onAddToCart: onAddToCart })) : (_jsxs(Div, { textSize: "sm", className: "border-dashed", border: "strong", surface: "muted", rounded: "xl", padding: "md", "aria-live": "polite", children: [_jsx(Text, { weight: "semibold", className: "mb-1 block", children: BUNDLE_COPY.detail.ctaDisabled }), _jsx(Text, { size: "sm", color: "muted", children: BUNDLE_COPY.detail.ctaHint })] }))] })] }), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: BUNDLE_COPY.detailItemsHeading }), members.length === 0 ? (_jsx(Text, { color: "muted", children: BUNDLE_COPY.detail.emptyMembers })) : (_jsx(Ul, { gap: "2", className: "grid grid-cols-1 sm:grid-cols-2", children: members.map((p, i) => (_jsxs(Li, { layout: "flex-center", gap: "2", rounded: "lg", border: "subtle", padding: "inlineSm", textSize: "sm", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "w-5", color: "faint", children: ["#", i + 1] }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#"), className: "flex-1 truncate font-medium hover:underline hover:text-primary", children: p.title }), _jsx(Span, { size: "xs", color: "muted", children: _jsx(GatedPrice, { children: formatCurrency(p.price ?? 0, p.currency ?? "INR") }) })] }, p.id))) }))] })] }) }) }), relatedBundles.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Bundles" }), _jsx(CategoryGrid, { categories: relatedBundles, getHref: (c) => String(ROUTES.PUBLIC.BUNDLE_DETAIL(c.slug)) })] }) }))] }));
|
|
29
33
|
}
|
|
@@ -155,5 +155,5 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
155
155
|
const hasCover = Boolean(coverImage);
|
|
156
156
|
return (_jsxs(Main, { children: [_jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORIES), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Categories" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: category?.name ?? slug })] }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), childCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "inline", children: _jsx(Div, { layout: "flex", gap: "2", className: `.5 ${__O.xAuto} [scrollbar-width:none]`, padding: "b-2xs", children: childCategories.map((child) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(child.slug)),
|
|
157
157
|
// audit-responsive-wrap-ok: deliberate horizontal-scroll chip row (parent has overflow-x-auto) — each chip staying single-line is the intended design, not hidden content
|
|
158
|
-
className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1-5)] rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] px-[var(--appkit-space-4)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary transition-colors whitespace-nowrap", children: [child.display?.icon && (_jsx(Span, { className: "leading-none", children: child.display.icon })), child.name, (child.metrics?.productCount ?? 0) > 0 && (_jsx(Span, { size: "xs", color: "muted", children: (child.metrics?.productCount ?? 0).toLocaleString() }))] }, child.id))) }) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
|
|
158
|
+
className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1-5)] rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] px-[var(--appkit-space-4)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary transition-colors whitespace-nowrap", children: [child.display?.icon && (_jsx(Span, { className: "leading-none", children: child.display.icon })), child.name, (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0) > 0 && (_jsx(Span, { size: "xs", color: "muted", children: (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0).toLocaleString() }))] }, child.id))) }) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
|
|
159
159
|
}
|