@mohasinac/appkit 4.42.18 → 4.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/_internal/client/features/lottery/LotteryDetailView.js +22 -1
  2. package/dist/_internal/server/features/products/list-public.d.ts +24 -0
  3. package/dist/_internal/server/features/products/list-public.js +10 -2
  4. package/dist/_internal/server/jobs/core/onCategoryWrite.js +5 -0
  5. package/dist/_internal/server/jobs/core/onProductWrite.js +31 -0
  6. package/dist/_internal/server/jobs/core/onReviewWrite.js +8 -0
  7. package/dist/_internal/server/jobs/core/onStoreWrite.d.ts +1 -1
  8. package/dist/_internal/server/jobs/core/onStoreWrite.js +8 -2
  9. package/dist/_internal/server/jobs/core/revalidateNotify.d.ts +28 -0
  10. package/dist/_internal/server/jobs/core/revalidateNotify.js +125 -0
  11. package/dist/_internal/shared/cache/revalidate-targets.d.ts +49 -0
  12. package/dist/_internal/shared/cache/revalidate-targets.js +157 -0
  13. package/dist/features/auctions/components/AuctionBottomActions.js +10 -1
  14. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  15. package/dist/features/auctions/components/AuctionLiveStatusBadge.d.ts +20 -0
  16. package/dist/features/auctions/components/AuctionLiveStatusBadge.js +21 -0
  17. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
  18. package/dist/features/auctions/components/PlaceBidFormClient.js +20 -2
  19. package/dist/features/auctions/hooks/useIsAuctionEnded.d.ts +41 -0
  20. package/dist/features/auctions/hooks/useIsAuctionEnded.js +76 -0
  21. package/dist/features/blog/api/route.js +16 -1
  22. package/dist/features/categories/actions/category-actions.d.ts +2 -2
  23. package/dist/features/categories/actions/category-actions.js +22 -10
  24. package/dist/features/categories/components/CategoriesIndexListing.d.ts +1 -3
  25. package/dist/features/categories/components/CategoriesIndexListing.js +1 -1
  26. package/dist/features/categories/components/CategoriesIndexPageView.d.ts +27 -1
  27. package/dist/features/categories/components/CategoriesIndexPageView.js +24 -33
  28. package/dist/features/categories/repository/categories.repository.d.ts +11 -1
  29. package/dist/features/categories/repository/categories.repository.js +25 -7
  30. package/dist/features/faq/utils/getHomepageFaqsCached.d.ts +20 -0
  31. package/dist/features/faq/utils/getHomepageFaqsCached.js +22 -0
  32. package/dist/features/homepage/components/MarketplaceHomepageView.js +13 -3
  33. package/dist/features/homepage/lib/live-stats.js +18 -8
  34. package/dist/features/media/MediaImage.js +19 -2
  35. package/dist/features/products/actions/product-actions.d.ts +0 -9
  36. package/dist/features/products/actions/product-actions.js +25 -0
  37. package/dist/features/products/components/ArtStickersListView.js +7 -1
  38. package/dist/features/products/components/PrizeDrawsSection.js +5 -0
  39. package/dist/features/products/components/ProductsIndexPageView.js +1 -1
  40. package/dist/features/reviews/repository/reviews.repository.d.ts +22 -0
  41. package/dist/features/reviews/repository/reviews.repository.js +35 -0
  42. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +2 -2
  43. package/dist/seed/_helpers/media.d.ts +1 -1
  44. package/dist/seed/_helpers/media.js +44 -8
  45. package/dist/seed/orders-seed-data.js +5 -3
  46. package/dist/server.d.ts +2 -0
  47. package/dist/server.js +6 -0
  48. package/dist/utils/media-url.d.ts +26 -0
  49. package/dist/utils/media-url.js +144 -0
  50. package/package.json +1 -1
@@ -22,6 +22,7 @@ import { AuctionDetailView } from "../../products/components/AuctionDetailView";
22
22
  import { ProductTabsShell } from "../../products/components/ProductTabsShell";
23
23
  import { CustomSectionTabContent } from "../../products/components/CustomSectionTabContent";
24
24
  import { AuctionBottomActions } from "./AuctionBottomActions";
25
+ import { AuctionLiveStatusBadge } from "./AuctionLiveStatusBadge";
25
26
  import { RelatedProducts } from "../../products/components/RelatedProducts";
26
27
  import { ProductGalleryClient } from "../../products/components/ProductGalleryClient";
27
28
  import { ProductFeatureBadges } from "../../products/components/ProductFeatureBadges";
@@ -48,7 +49,7 @@ function toDescriptionHtml(raw) {
48
49
  }
49
50
  function renderAuctionInfoPanel(props) {
50
51
  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: _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
+ 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" }), _jsx(AuctionLiveStatusBadge, { auctionEndDate: endDate, initialIsEnded: isEnded })] }), _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
53
  }
53
54
  /**
54
55
  * Store reviews for the auction's seller.
@@ -0,0 +1,20 @@
1
+ export interface AuctionLiveStatusBadgeProps {
2
+ /** The auction's end timestamp — the only thing that decides this. */
3
+ auctionEndDate: Date | string | null;
4
+ /** The server's answer, used as the initial value so SSR and hydration agree. */
5
+ initialIsEnded: boolean;
6
+ }
7
+ /**
8
+ * Active / Ended, decided on the client.
9
+ *
10
+ * The server-rendered version of this badge sat directly above a countdown that
11
+ * already flipped itself to "Ended" — so a cached auction page could render
12
+ * `Ends in Ended` beside a green **Active** pill. The countdown was right and the
13
+ * badge was stale, in the same `<Stack>`.
14
+ *
15
+ * Extracted to a client component rather than passing a derived prop down,
16
+ * because `AuctionDetailPageView` is a Server Component and cannot hold the
17
+ * timer. Colours follow the status-pair rule (Root Cause #67): a theme-relative
18
+ * surface with its matching theme-relative ink, never a literal `text-white`.
19
+ */
20
+ export declare function AuctionLiveStatusBadge({ auctionEndDate, initialIsEnded, }: AuctionLiveStatusBadgeProps): import("react").JSX.Element;
@@ -0,0 +1,21 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Span } from "../../../ui";
4
+ import { useIsAuctionEnded } from "../hooks/useIsAuctionEnded";
5
+ /**
6
+ * Active / Ended, decided on the client.
7
+ *
8
+ * The server-rendered version of this badge sat directly above a countdown that
9
+ * already flipped itself to "Ended" — so a cached auction page could render
10
+ * `Ends in Ended` beside a green **Active** pill. The countdown was right and the
11
+ * badge was stale, in the same `<Stack>`.
12
+ *
13
+ * Extracted to a client component rather than passing a derived prop down,
14
+ * because `AuctionDetailPageView` is a Server Component and cannot hold the
15
+ * timer. Colours follow the status-pair rule (Root Cause #67): a theme-relative
16
+ * surface with its matching theme-relative ink, never a literal `text-white`.
17
+ */
18
+ export function AuctionLiveStatusBadge({ auctionEndDate, initialIsEnded, }) {
19
+ const isEnded = useIsAuctionEnded(auctionEndDate, initialIsEnded);
20
+ return 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" }));
21
+ }
@@ -45,7 +45,7 @@ export interface PlaceBidFormClientProps {
45
45
  onPlaceBid: (input: PlaceBidInput) => Promise<BidActionEnvelope<unknown>>;
46
46
  onBuyNow?: () => Promise<BidActionEnvelope<BuyNowSuccess>>;
47
47
  }
48
- export declare function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, startingBid, tiers, minBidIncrementOverride, currency, isEnded, auctionEndDate, buyNowPrice, bidCount: ssrBidCount, tags, onPlaceBid, onBuyNow, }: PlaceBidFormClientProps): React.JSX.Element;
48
+ export declare function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, startingBid, tiers, minBidIncrementOverride, currency, isEnded: ssrIsEnded, auctionEndDate, buyNowPrice, bidCount: ssrBidCount, tags, onPlaceBid, onBuyNow, }: PlaceBidFormClientProps): React.JSX.Element;
49
49
  /**
50
50
  * `PlaceBidModalButton` — opens a modal that hosts the same PlaceBidFormClient.
51
51
  *
@@ -13,6 +13,7 @@ import { FieldInput } from "../../../ui/forms/FieldInput";
13
13
  import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
14
14
  import { placeBidSchema } from "../schemas/bid-input";
15
15
  import { useLiveAuctionBid } from "../hooks/useLiveAuctionBid";
16
+ import { useIsAuctionEnded } from "../hooks/useIsAuctionEnded";
16
17
  import { AUCTION_BUYOUT_WINDOW_MINUTES } from "../../../_internal/shared/checkout/lanes";
17
18
  import { BID_PRESET_MULTIPLIERS, isBuyNowAvailable, resolveMinBid, resolveMinBidIncrement, } from "../../../_internal/shared/features/auctions/config";
18
19
  import { normalizeError } from "../../../errors/normalize";
@@ -36,7 +37,20 @@ const __P = {
36
37
  /** Feature-specific last resort. Authored copy, never derived from an error. */
37
38
  const BID_FALLBACK = "Failed to place bid. Please try again.";
38
39
  const BUY_NOW_FALLBACK = "Buy Now failed. Please try again.";
39
- export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, startingBid, tiers, minBidIncrementOverride, currency, isEnded, auctionEndDate, buyNowPrice, bidCount: ssrBidCount, tags = [], onPlaceBid, onBuyNow, }) {
40
+ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, startingBid, tiers, minBidIncrementOverride, currency, isEnded: ssrIsEnded, auctionEndDate, buyNowPrice, bidCount: ssrBidCount, tags = [], onPlaceBid, onBuyNow, }) {
41
+ /*
42
+ * 🛑 Derived here, not trusted from the server.
43
+ *
44
+ * `isEnded` arrives baked into cached HTML. On a page cached while the auction
45
+ * was live it says "still open" indefinitely, so every gate below it — the
46
+ * amount field, the presets, the submit button, Buy Now — stayed enabled on a
47
+ * closed lot. The server prop is the INITIAL value only; `auctionEndDate` was
48
+ * already being passed in and used for display, and it is the real answer.
49
+ *
50
+ * The bid itself was never at risk: `placeBid` re-reads the product and
51
+ * rejects on an expired `auctionEndDate`. This fixes the affordance.
52
+ */
53
+ const isEnded = useIsAuctionEnded(auctionEndDate, ssrIsEnded);
40
54
  const router = useRouter();
41
55
  // Declared with the other hooks, never inside the early return below — a
42
56
  // conditional hook is a render-order violation, and this component early-
@@ -277,5 +291,9 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
277
291
  export function PlaceBidModalButton(props) {
278
292
  const { triggerLabel = "Place a bid", triggerClassName = "", ...formProps } = props;
279
293
  const [open, setOpen] = useState(false);
280
- return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "primary", size: "md", className: triggerClassName, disabled: props.isEnded, onClick: () => setOpen(true), children: props.isEnded ? "Auction Ended" : triggerLabel }), _jsx(Modal, { isOpen: open, onClose: () => setOpen(false), size: "md", title: "Place your bid", children: _jsx(PlaceBidFormClient, { ...formProps }) })] }));
294
+ // Same reason as inside the form: this trigger is THE affordance a buyer sees
295
+ // on a cached auction page, so it must decide for itself rather than trust a
296
+ // boolean that was true whenever the HTML happened to be generated.
297
+ const isEnded = useIsAuctionEnded(props.auctionEndDate, props.isEnded);
298
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "primary", size: "md", className: triggerClassName, disabled: isEnded, onClick: () => setOpen(true), children: isEnded ? "Auction Ended" : triggerLabel }), _jsx(Modal, { isOpen: open, onClose: () => setOpen(false), size: "md", title: "Place your bid", children: _jsx(PlaceBidFormClient, { ...formProps }) })] }));
281
299
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Has this auction ended, decided on the CLIENT from its end timestamp.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `AuctionDetailPageView` computed `const isEnded = endDate < new Date()` on the
7
+ * SERVER and froze the answer into eleven places — the status badge, the Buy-Now
8
+ * chip, the SSE `enabled` flag, the Place-Bid button, the mobile bid card and
9
+ * the bottom action bar. That is correct only for as long as the rendered HTML
10
+ * is fresh.
11
+ *
12
+ * The page is cached. At `revalidate = 30` the window was small enough to hide
13
+ * the problem; it is not small enough to be *correct*, and raising the TTL (so
14
+ * that 95 auction pages stop regenerating up to 2,880 times a day each, which is
15
+ * what produced 3.1M ISR writes) makes it plainly visible: a page cached while
16
+ * the auction was live renders a green **Active** badge and an enabled "Place a
17
+ * bid" button on a lot that closed hours ago.
18
+ *
19
+ * The timestamp needed to know better was already being passed down — it was
20
+ * used for display only.
21
+ *
22
+ * ## What this does NOT change
23
+ *
24
+ * Server-side enforcement is independent and already correct: `placeBid` and
25
+ * `buyNowAuction` both re-read the product and reject on `endDate < now`, and
26
+ * `assertAuctionActive()` is a third copy. A stale page could never actually
27
+ * place a bid — the defect was always a misleading affordance, never a bad
28
+ * write. That is what makes this safe to adopt incrementally: every component
29
+ * converted is an improvement, and an unconverted one is no worse than today.
30
+ *
31
+ * ## Behaviour
32
+ *
33
+ * - Starts from `initial` (the server's answer) so the first paint is unchanged
34
+ * and hydration does not mismatch.
35
+ * - Re-checks on mount, which is where a cached page corrects itself.
36
+ * - Schedules ONE timer for the exact moment of expiry rather than ticking every
37
+ * second — a bid button does not need per-second resolution, and an interval
38
+ * on every auction card would be a needless wakeup. Long waits are clamped to
39
+ * the setTimeout 32-bit ceiling and re-armed.
40
+ */
41
+ export declare function useIsAuctionEnded(auctionEndDate: Date | string | null | undefined, initial?: boolean): boolean;
@@ -0,0 +1,76 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ /**
4
+ * Has this auction ended, decided on the CLIENT from its end timestamp.
5
+ *
6
+ * ## Why this exists
7
+ *
8
+ * `AuctionDetailPageView` computed `const isEnded = endDate < new Date()` on the
9
+ * SERVER and froze the answer into eleven places — the status badge, the Buy-Now
10
+ * chip, the SSE `enabled` flag, the Place-Bid button, the mobile bid card and
11
+ * the bottom action bar. That is correct only for as long as the rendered HTML
12
+ * is fresh.
13
+ *
14
+ * The page is cached. At `revalidate = 30` the window was small enough to hide
15
+ * the problem; it is not small enough to be *correct*, and raising the TTL (so
16
+ * that 95 auction pages stop regenerating up to 2,880 times a day each, which is
17
+ * what produced 3.1M ISR writes) makes it plainly visible: a page cached while
18
+ * the auction was live renders a green **Active** badge and an enabled "Place a
19
+ * bid" button on a lot that closed hours ago.
20
+ *
21
+ * The timestamp needed to know better was already being passed down — it was
22
+ * used for display only.
23
+ *
24
+ * ## What this does NOT change
25
+ *
26
+ * Server-side enforcement is independent and already correct: `placeBid` and
27
+ * `buyNowAuction` both re-read the product and reject on `endDate < now`, and
28
+ * `assertAuctionActive()` is a third copy. A stale page could never actually
29
+ * place a bid — the defect was always a misleading affordance, never a bad
30
+ * write. That is what makes this safe to adopt incrementally: every component
31
+ * converted is an improvement, and an unconverted one is no worse than today.
32
+ *
33
+ * ## Behaviour
34
+ *
35
+ * - Starts from `initial` (the server's answer) so the first paint is unchanged
36
+ * and hydration does not mismatch.
37
+ * - Re-checks on mount, which is where a cached page corrects itself.
38
+ * - Schedules ONE timer for the exact moment of expiry rather than ticking every
39
+ * second — a bid button does not need per-second resolution, and an interval
40
+ * on every auction card would be a needless wakeup. Long waits are clamped to
41
+ * the setTimeout 32-bit ceiling and re-armed.
42
+ */
43
+ export function useIsAuctionEnded(auctionEndDate, initial = false) {
44
+ const endMs = toMs(auctionEndDate);
45
+ const [ended, setEnded] = useState(initial);
46
+ useEffect(() => {
47
+ if (endMs === null) {
48
+ // No end date — the server's answer is the only one available.
49
+ setEnded(initial);
50
+ return;
51
+ }
52
+ let timer;
53
+ const evaluate = () => {
54
+ const remaining = endMs - Date.now();
55
+ if (remaining <= 0) {
56
+ setEnded(true);
57
+ return;
58
+ }
59
+ setEnded(false);
60
+ // setTimeout overflows past ~24.8 days and fires immediately; re-arm.
61
+ timer = setTimeout(evaluate, Math.min(remaining, 2147483000));
62
+ };
63
+ evaluate();
64
+ return () => {
65
+ if (timer)
66
+ clearTimeout(timer);
67
+ };
68
+ }, [endMs, initial]);
69
+ return ended;
70
+ }
71
+ function toMs(value) {
72
+ if (!value)
73
+ return null;
74
+ const ms = value instanceof Date ? value.getTime() : new Date(value).getTime();
75
+ return Number.isFinite(ms) ? ms : null;
76
+ }
@@ -72,7 +72,22 @@ export async function GET(request) {
72
72
  posts: result.items,
73
73
  meta,
74
74
  };
75
- return NextResponse.json({ success: true, data: body });
75
+ const response = NextResponse.json({ success: true, data: body });
76
+ /*
77
+ * The only public listing API that set NO Cache-Control — `/api/products`,
78
+ * `/api/stores`, `/api/events` and `/api/reviews` all do, and CLAUDE.md
79
+ * Rule #6.5 requires it of every public GET (20 of 378 routes complied).
80
+ *
81
+ * It mattered little while anonymous browsing never reached this route (the
82
+ * dynamic `/blog` page rendered server-side and seeded the client), but it
83
+ * is on the critical path the moment a filtered view misses the page cache.
84
+ * Window matched to its siblings deliberately — a blog post is no more
85
+ * volatile than a product.
86
+ *
87
+ * Errors below stay uncached: a 500 must not be pinned at the edge.
88
+ */
89
+ response.headers.set("Cache-Control", "public, max-age=60, s-maxage=120, stale-while-revalidate=60");
90
+ return response;
76
91
  }
77
92
  catch (error) {
78
93
  void normalizeError(error);
@@ -9,8 +9,8 @@ export declare function listCategories(params?: {
9
9
  page?: number;
10
10
  pageSize?: number;
11
11
  }): Promise<FirebaseSieveResult<CategoryDocument>>;
12
- export declare function listTopLevelCategories(limit?: number): Promise<CategoryDocument[]>;
13
- export declare function listBrandCategories(limit?: number): Promise<CategoryDocument[]>;
12
+ export declare const listTopLevelCategories: (limit?: number) => Promise<CategoryDocument[]>;
13
+ export declare const listBrandCategories: (limit?: number) => Promise<CategoryDocument[]>;
14
14
  export declare function getCategoryById(id: string): Promise<CategoryDocument | null>;
15
15
  export declare function getCategoryBySlug(slug: string): Promise<CategoryDocument | null>;
16
16
  export declare function getCategoryChildren(parentId: string): Promise<CategoryDocument[]>;
@@ -1,3 +1,4 @@
1
+ import { cache } from "react";
1
2
  import { serverLogger } from "../../../monitoring";
2
3
  import { categoriesRepository } from "../repository/categories.repository";
3
4
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility";
@@ -39,19 +40,30 @@ export async function listCategories(params) {
39
40
  };
40
41
  return categoriesRepository.list(sieve);
41
42
  }
42
- export async function listTopLevelCategories(limit = 12) {
43
- const all = await categoriesRepository.getCategoriesByTier(0);
43
+ /*
44
+ * Both of these fetched the ENTIRE matching set and `.slice()`d it to 12, on
45
+ * every homepage render. The bound is now pushed into Firestore (with headroom
46
+ * for the post-fetch filters), and both are `cache()`-wrapped so a second reader
47
+ * in the same request tree — the nav, a footer, a sibling section — costs
48
+ * nothing. Measured 2026-10-09 against a 50K-reads/day free tier.
49
+ *
50
+ * The in-memory `isActive !== false` filter is gone: both queries already
51
+ * require `isActive == true`, and a Firestore equality excludes documents
52
+ * lacking the field, so it could never match anything the query had not already
53
+ * admitted. `hidePublicTestData` stays — that one is a real post-filter, which
54
+ * is why the repository fetches with headroom rather than exactly `limit`.
55
+ */
56
+ export const listTopLevelCategories = cache(async (limit = 12) => {
57
+ const all = await categoriesRepository.getCategoriesByTier(0, limit);
44
58
  return hidePublicTestData(all)
45
- .filter((c) => c.isActive !== false)
46
59
  .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
47
60
  .slice(0, limit);
48
- }
49
- export async function listBrandCategories(limit = 12) {
50
- const brands = await categoriesRepository.getBrandCategories();
51
- return hidePublicTestData(brands)
52
- .filter((c) => c.isActive !== false)
53
- .slice(0, limit);
54
- }
61
+ });
62
+ export const listBrandCategories = cache(async (limit = 12) => {
63
+ // Over-fetch a small margin so `hidePublicTestData` cannot under-fill the row.
64
+ const brands = await categoriesRepository.getBrandCategories(limit + 8);
65
+ return hidePublicTestData(brands).slice(0, limit);
66
+ });
55
67
  export async function getCategoryById(id) {
56
68
  return categoriesRepository.findById(id);
57
69
  }
@@ -1,8 +1,6 @@
1
1
  import React from "react";
2
- import type { CategoryItem } from "../types";
3
2
  export interface CategoriesIndexListingProps {
4
- initialData?: CategoryItem[];
5
3
  /** When true, filters to brands only (for the standalone /brands page) */
6
4
  brandsOnly?: boolean;
7
5
  }
8
- export declare function CategoriesIndexListing({ initialData: _, brandsOnly }: CategoriesIndexListingProps): React.JSX.Element;
6
+ export declare function CategoriesIndexListing({ brandsOnly }: CategoriesIndexListingProps): React.JSX.Element;
@@ -63,7 +63,7 @@ const SORT_OPTIONS = [
63
63
  { value: sortBy(CATEGORY_FIELDS.NAME), label: "Name Z–A" },
64
64
  { value: sortBy("metrics.productCount", "DESC"), label: "Most Products" },
65
65
  ];
66
- export function CategoriesIndexListing({ initialData: _, brandsOnly = false }) {
66
+ export function CategoriesIndexListing({ brandsOnly = false }) {
67
67
  const table = useUrlTable({ defaults: { pageSize: String(PAGE_SIZE), sort: DEFAULT_SORT } });
68
68
  const [searchInput, setSearchInput] = useState(table.get(TABLE_KEYS.QUERY) || "");
69
69
  const [filterOpen, setFilterOpen] = useState(false);
@@ -1,7 +1,33 @@
1
1
  import React from "react";
2
2
  type SearchParams = Record<string, string | string[]>;
3
3
  export interface CategoriesIndexPageViewProps {
4
+ /**
5
+ * @deprecated Accepted and ignored. Kept so the consumer page shim compiles
6
+ * either way; remove once no caller passes it.
7
+ */
4
8
  searchParams?: SearchParams;
5
9
  }
6
- export declare function CategoriesIndexPageView({ searchParams }: CategoriesIndexPageViewProps): Promise<React.JSX.Element>;
10
+ /**
11
+ * The categories index.
12
+ *
13
+ * 🛑 This view deliberately performs NO Firestore read.
14
+ *
15
+ * It used to fetch `pageSize: 200` and hand the result to
16
+ * `CategoriesIndexListing` as `initialData` — which destructured it as
17
+ * `initialData: _` and threw it away, then fetched `/api/categories?flat=true`
18
+ * regardless. So the read was 200 documents per render, on a route that
19
+ * regenerated every 300s, rendering nothing. Measured 2026-10-09 while tracing
20
+ * 2.1M Firestore reads/week against a 50K/day free tier.
21
+ *
22
+ * Deleting it also removes this page from Root Cause #30's blast radius
23
+ * entirely: you cannot freeze the wrong data under a `staleTime: Infinity` key
24
+ * you never seeded. The long "MUST MATCH CategoriesIndexListing's DEFAULT_SORT"
25
+ * warning that used to live here is now moot for the same reason — there is no
26
+ * second parser to keep in sync.
27
+ *
28
+ * Unlike the other listing pages, making this one static costs no SEO: its grid
29
+ * was already client-rendered, so there was never a server-rendered grid to
30
+ * lose (contrast `/products`, where the grid IS in the SSR HTML and must stay).
31
+ */
32
+ export declare function CategoriesIndexPageView(_props?: CategoriesIndexPageViewProps): Promise<React.JSX.Element>;
7
33
  export {};
@@ -1,13 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
3
- import { sortBy } from "../../../constants/sort";
4
- import { CATEGORY_FIELDS } from "../../../constants/field-names";
5
- import { categoriesRepository } from "../../../repositories";
6
3
  import { Container, Heading, Main, Section } from "../../../ui";
7
4
  import { AdSlot } from "../../homepage/components/AdSlot";
8
5
  import { CategoriesIndexListing } from "./CategoriesIndexListing";
9
- import { safeRead } from "../../../errors/safe-read";
10
- import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility";
11
6
  function sp(params, key) {
12
7
  const v = params[key];
13
8
  return Array.isArray(v) ? v[0] ?? "" : v ?? "";
@@ -39,32 +34,28 @@ function buildCategoryFilters(params) {
39
34
  }
40
35
  return parts.join(",");
41
36
  }
42
- export async function CategoriesIndexPageView({ searchParams = {} }) {
43
- /*
44
- * 🛑 MUST MATCH CategoriesIndexListing's DEFAULT_SORT.
45
- *
46
- * This defaulted to "name" while the client listing defaulted to the same,
47
- * and both were wrong: the roots fell off page 1. Now both default to tier
48
- * ascending — and they have to agree, because this view hands its result to
49
- * the listing as initialData and every public listing hook sets
50
- * `staleTime: Infinity` when given it. A disagreement here is not a
51
- * transient wrong first paint; it is frozen for that query key
52
- * (Root Cause #30).
53
- */
54
- const sort = sp(searchParams, "sort") || sortBy(CATEGORY_FIELDS.TIER, "ASC");
55
- const page = Number(sp(searchParams, "page")) || 1;
56
- const filters = buildCategoryFilters(searchParams);
57
- const result = await safeRead(() => categoriesRepository.list({
58
- page,
59
- pageSize: 200,
60
- sorts: sort,
61
- ...(filters ? { filters } : {}),
62
- }), { route: "/categories", key: "categories.list", fallback: null });
63
- /*
64
- * This one page carries THREE entity kinds — categories, brands and bundles
65
- * are all `CategoryDocument` rows discriminated by `categoryType` — so a
66
- * single unfiltered read published sandbox fixtures of all three.
67
- */
68
- const initialData = hidePublicTestData((result?.items ?? []));
69
- return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Categories" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(CategoriesIndexListing, { initialData: initialData }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
37
+ /**
38
+ * The categories index.
39
+ *
40
+ * 🛑 This view deliberately performs NO Firestore read.
41
+ *
42
+ * It used to fetch `pageSize: 200` and hand the result to
43
+ * `CategoriesIndexListing` as `initialData` — which destructured it as
44
+ * `initialData: _` and threw it away, then fetched `/api/categories?flat=true`
45
+ * regardless. So the read was 200 documents per render, on a route that
46
+ * regenerated every 300s, rendering nothing. Measured 2026-10-09 while tracing
47
+ * 2.1M Firestore reads/week against a 50K/day free tier.
48
+ *
49
+ * Deleting it also removes this page from Root Cause #30's blast radius
50
+ * entirely: you cannot freeze the wrong data under a `staleTime: Infinity` key
51
+ * you never seeded. The long "MUST MATCH CategoriesIndexListing's DEFAULT_SORT"
52
+ * warning that used to live here is now moot for the same reason — there is no
53
+ * second parser to keep in sync.
54
+ *
55
+ * Unlike the other listing pages, making this one static costs no SEO: its grid
56
+ * was already client-rendered, so there was never a server-rendered grid to
57
+ * lose (contrast `/products`, where the grid IS in the SSR HTML and must stay).
58
+ */
59
+ export async function CategoriesIndexPageView(_props = {}) {
60
+ return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Categories" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(CategoriesIndexListing, {}), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
70
61
  }
@@ -9,7 +9,17 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
9
9
  getCategoryBySlug(slug: string): Promise<CategoryDocument | null>;
10
10
  getRootCategories(): Promise<CategoryDocument[]>;
11
11
  getLeafCategories(): Promise<CategoryDocument[]>;
12
- getCategoriesByTier(tier: number): Promise<CategoryDocument[]>;
12
+ /**
13
+ * @param limit 0 = unbounded (the historical behaviour). When > 0 the bound is
14
+ * pushed into Firestore instead of slicing after the read.
15
+ *
16
+ * 🛑 The limit is applied with HEADROOM, not exactly, because `!isBrand` is a
17
+ * post-fetch filter: brand rows live in this same collection (they are
18
+ * `CategoryDocument`s discriminated by `isBrand`), so a bare `.limit(n)` could
19
+ * return n brand rows, filter all of them out, and yield an empty tier. The
20
+ * caller still slices to its own limit.
21
+ */
22
+ getCategoriesByTier(tier: number, limit?: number): Promise<CategoryDocument[]>;
13
23
  getCategoriesByRootId(rootId: string): Promise<CategoryDocument[]>;
14
24
  getChildren(parentId: string): Promise<CategoryDocument[]>;
15
25
  /**
@@ -112,14 +112,26 @@ export class CategoriesRepository extends BaseRepository {
112
112
  throw new DatabaseError(`Failed to retrieve leaf categories: ${error instanceof Error ? error.message : "Unknown error"}`);
113
113
  }
114
114
  }
115
- async getCategoriesByTier(tier) {
115
+ /**
116
+ * @param limit 0 = unbounded (the historical behaviour). When > 0 the bound is
117
+ * pushed into Firestore instead of slicing after the read.
118
+ *
119
+ * 🛑 The limit is applied with HEADROOM, not exactly, because `!isBrand` is a
120
+ * post-fetch filter: brand rows live in this same collection (they are
121
+ * `CategoryDocument`s discriminated by `isBrand`), so a bare `.limit(n)` could
122
+ * return n brand rows, filter all of them out, and yield an empty tier. The
123
+ * caller still slices to its own limit.
124
+ */
125
+ async getCategoriesByTier(tier, limit = 0) {
116
126
  try {
117
- const snapshot = await this.db
127
+ let query = this.db
118
128
  .collection(this.collection)
119
129
  .where(CATEGORY_FIELDS.TIER, "==", tier)
120
130
  .where(CATEGORY_FIELDS.IS_ACTIVE, "==", true)
121
- .orderBy(CATEGORY_FIELDS.ORDER, "asc")
122
- .get();
131
+ .orderBy(CATEGORY_FIELDS.ORDER, "asc");
132
+ if (limit > 0)
133
+ query = query.limit(limit * 4);
134
+ const snapshot = await query.get();
123
135
  return snapshot.docs
124
136
  .map((doc) => this.mapDoc(doc))
125
137
  .filter((category) => !category.isBrand);
@@ -230,12 +242,18 @@ export class CategoriesRepository extends BaseRepository {
230
242
  }
231
243
  async getBrandCategories(limit = 0) {
232
244
  try {
233
- const snapshot = await this.db
245
+ // The limit is pushed into Firestore rather than sliced after the read.
246
+ // It used to fetch EVERY brand and `.slice()` — so `limit` reduced what
247
+ // the caller saw but not what was billed. Exact here (no post-filter to
248
+ // under-fill), unlike `getCategoriesByTier` above.
249
+ let query = this.db
234
250
  .collection(this.collection)
235
251
  .where("isBrand", "==", true)
236
252
  .where(CATEGORY_FIELDS.IS_ACTIVE, "==", true)
237
- .orderBy(CATEGORY_FIELDS.ORDER, "asc")
238
- .get();
253
+ .orderBy(CATEGORY_FIELDS.ORDER, "asc");
254
+ if (limit > 0)
255
+ query = query.limit(limit);
256
+ const snapshot = await query.get();
239
257
  const brands = snapshot.docs.map((doc) => this.mapDoc(doc));
240
258
  return limit > 0 ? brands.slice(0, limit) : brands;
241
259
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * React.cache deduplication — one Firestore read per RSC request tree.
3
+ *
4
+ * 🛑 `getHomepageFAQs()` was called TWICE on every homepage render, from two
5
+ * modules that could not see each other: `src/app/[locale]/page.tsx` builds the
6
+ * FAQ JSON-LD from it, and `MarketplaceHomepageView` renders the FAQ section
7
+ * from it. Two call sites, no shared wrapper, so two real queries — and the
8
+ * markup and the visible content had no structural guarantee of agreeing, which
9
+ * is a requirement Google actually enforces.
10
+ *
11
+ * Caching the NARROWEST shared call rather than either caller is the pattern
12
+ * CLAUDE.md § React.cache discipline prescribes, and it is a straight copy of
13
+ * `getSiteSettingsGlobal` (features/admin/utils/) — which exists for exactly the
14
+ * same reason and is the precedent this follows.
15
+ *
16
+ * Takes no arguments on purpose: `React.cache` memoises per argument list, so a
17
+ * parameterised version would give each distinct call its own entry and dedupe
18
+ * nothing.
19
+ */
20
+ export declare const getHomepageFaqsCached: () => Promise<import("..").FAQDocument[]>;
@@ -0,0 +1,22 @@
1
+ import { cache } from "react";
2
+ import { faqsRepository } from "../repository/faqs.repository";
3
+ /**
4
+ * React.cache deduplication — one Firestore read per RSC request tree.
5
+ *
6
+ * 🛑 `getHomepageFAQs()` was called TWICE on every homepage render, from two
7
+ * modules that could not see each other: `src/app/[locale]/page.tsx` builds the
8
+ * FAQ JSON-LD from it, and `MarketplaceHomepageView` renders the FAQ section
9
+ * from it. Two call sites, no shared wrapper, so two real queries — and the
10
+ * markup and the visible content had no structural guarantee of agreeing, which
11
+ * is a requirement Google actually enforces.
12
+ *
13
+ * Caching the NARROWEST shared call rather than either caller is the pattern
14
+ * CLAUDE.md § React.cache discipline prescribes, and it is a straight copy of
15
+ * `getSiteSettingsGlobal` (features/admin/utils/) — which exists for exactly the
16
+ * same reason and is the precedent this follows.
17
+ *
18
+ * Takes no arguments on purpose: `React.cache` memoises per argument list, so a
19
+ * parameterised version would give each distinct call its own entry and dedupe
20
+ * nothing.
21
+ */
22
+ export const getHomepageFaqsCached = cache(() => faqsRepository.getHomepageFAQs());