@mohasinac/appkit 4.42.17 → 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.
- package/dist/_internal/client/features/lottery/LotteryDetailView.js +22 -1
- package/dist/_internal/server/features/products/list-public.d.ts +24 -0
- package/dist/_internal/server/features/products/list-public.js +10 -2
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +5 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +31 -0
- package/dist/_internal/server/jobs/core/onReviewWrite.js +8 -0
- package/dist/_internal/server/jobs/core/onStoreWrite.d.ts +1 -1
- package/dist/_internal/server/jobs/core/onStoreWrite.js +8 -2
- package/dist/_internal/server/jobs/core/revalidateNotify.d.ts +28 -0
- package/dist/_internal/server/jobs/core/revalidateNotify.js +125 -0
- package/dist/_internal/shared/cache/revalidate-targets.d.ts +49 -0
- package/dist/_internal/shared/cache/revalidate-targets.js +157 -0
- package/dist/features/auctions/components/AuctionBottomActions.js +10 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auctions/components/AuctionLiveStatusBadge.d.ts +20 -0
- package/dist/features/auctions/components/AuctionLiveStatusBadge.js +21 -0
- package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
- package/dist/features/auctions/components/PlaceBidFormClient.js +20 -2
- package/dist/features/auctions/hooks/useIsAuctionEnded.d.ts +41 -0
- package/dist/features/auctions/hooks/useIsAuctionEnded.js +76 -0
- package/dist/features/blog/api/route.js +16 -1
- package/dist/features/categories/actions/category-actions.d.ts +2 -2
- package/dist/features/categories/actions/category-actions.js +22 -10
- package/dist/features/categories/components/CategoriesIndexListing.d.ts +1 -3
- package/dist/features/categories/components/CategoriesIndexListing.js +1 -1
- package/dist/features/categories/components/CategoriesIndexPageView.d.ts +27 -1
- package/dist/features/categories/components/CategoriesIndexPageView.js +24 -33
- package/dist/features/categories/repository/categories.repository.d.ts +11 -1
- package/dist/features/categories/repository/categories.repository.js +25 -7
- package/dist/features/faq/utils/getHomepageFaqsCached.d.ts +20 -0
- package/dist/features/faq/utils/getHomepageFaqsCached.js +22 -0
- package/dist/features/homepage/components/MarketplaceHomepageView.js +13 -3
- package/dist/features/homepage/lib/live-stats.js +18 -8
- package/dist/features/media/MediaImage.js +19 -2
- package/dist/features/products/actions/product-actions.d.ts +0 -9
- package/dist/features/products/actions/product-actions.js +25 -0
- package/dist/features/products/components/ArtStickersListView.js +7 -1
- package/dist/features/products/components/PrizeDrawsSection.js +5 -0
- package/dist/features/products/components/ProductsIndexPageView.js +1 -1
- package/dist/features/reviews/repository/reviews.repository.d.ts +22 -0
- package/dist/features/reviews/repository/reviews.repository.js +35 -0
- package/dist/features/seller/components/SellerBidsView.js +12 -2
- package/dist/features/tester/seed-data/authored/buying__product-detail.js +14 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +2 -2
- package/dist/seed/_helpers/media.d.ts +1 -1
- package/dist/seed/_helpers/media.js +44 -8
- package/dist/seed/orders-seed-data.js +5 -3
- package/dist/seed/products-live-items-seed-data.js +42 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +6 -0
- package/dist/utils/media-url.d.ts +26 -0
- package/dist/utils/media-url.js +144 -0
- 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" }),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
export declare
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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());
|