@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
|
@@ -2,7 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
|
|
3
3
|
import { sortBy } from "@mohasinac/appkit";
|
|
4
4
|
import { Div, Main } from "../../../ui";
|
|
5
|
-
import { carouselRepository
|
|
5
|
+
import { carouselRepository } from "../../../repositories";
|
|
6
|
+
// Both are React.cache wrappers around a single repository call, shared with
|
|
7
|
+
// other readers in the same request tree (the locale layout for settings,
|
|
8
|
+
// page.tsx's FAQ JSON-LD for the FAQs). Importing the repositories directly
|
|
9
|
+
// here is what made each of those a second Firestore read.
|
|
10
|
+
import { getSiteSettingsGlobal } from "../../admin/utils/getSiteSettingsGlobal";
|
|
11
|
+
import { getHomepageFaqsCached } from "../../faq/utils/getHomepageFaqsCached";
|
|
6
12
|
import { safeRead } from "../../../errors/safe-read";
|
|
7
13
|
import { fetchLiveStats } from "../lib/live-stats";
|
|
8
14
|
import { renderSection, AnnouncementBar } from "../lib/section-renderer";
|
|
@@ -46,7 +52,10 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
|
|
|
46
52
|
const slides = await safeRead(() => carouselRepository.getActiveSlides(), {
|
|
47
53
|
route: "/", key: "homepage.carouselSlides", fallback: [],
|
|
48
54
|
});
|
|
49
|
-
|
|
55
|
+
// getSiteSettingsGlobal, not the repository directly — the locale layout and
|
|
56
|
+
// generateMetadata already read the same singleton through it, so this is one
|
|
57
|
+
// Firestore read for the whole request tree instead of a second one.
|
|
58
|
+
const siteSettings = await safeRead(() => getSiteSettingsGlobal(), {
|
|
50
59
|
route: "/", key: "homepage.siteSettings", fallback: null,
|
|
51
60
|
});
|
|
52
61
|
// The fallback used to be a hardcoded seasonal Pokémon TCG discount, which
|
|
@@ -58,7 +67,8 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
|
|
|
58
67
|
safeRead(() => homepageSectionsRepository.getEnabledSections(), {
|
|
59
68
|
route: "/", key: "homepage.enabledSections", fallback: [],
|
|
60
69
|
}),
|
|
61
|
-
|
|
70
|
+
// Shared with the FAQ JSON-LD built in src/app/[locale]/page.tsx — one read.
|
|
71
|
+
safeRead(() => getHomepageFaqsCached(), {
|
|
62
72
|
route: "/", key: "homepage.faqs", fallback: [],
|
|
63
73
|
}),
|
|
64
74
|
]);
|
|
@@ -47,16 +47,26 @@ export async function fetchLiveStats(requests) {
|
|
|
47
47
|
.catch((err) => { void normalizeError(err); serverLogger.warn("live-stats: total_orders query failed — stat omitted", { error: err instanceof Error ? err.message : String(err) }); }));
|
|
48
48
|
}
|
|
49
49
|
if (neededPresets.has("total_reviews") || neededPresets.has("platform_rating")) {
|
|
50
|
-
tasks.push(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
tasks.push(
|
|
51
|
+
/*
|
|
52
|
+
* 🛑 One aggregation, ~1 read — was `findAll()`, an UNBOUNDED full-collection
|
|
53
|
+
* scan of `reviews` on every cold homepage render, whose entire output was
|
|
54
|
+
* these two strings. Billed one read per document, it was the largest single
|
|
55
|
+
* Firestore read on the site (Hobby free tier is 50K reads/day, and the
|
|
56
|
+
* project measured 2.1M/week).
|
|
57
|
+
*
|
|
58
|
+
* The dropped `!r.status ||` branch was dead: `status` is a REQUIRED field
|
|
59
|
+
* defaulting to `"pending"` (features/reviews/schemas/firestore.ts), so no
|
|
60
|
+
* stored review lacks it, and treating a missing status as approved would
|
|
61
|
+
* have counted unmoderated reviews anyway.
|
|
62
|
+
*/
|
|
63
|
+
reviewRepository.getApprovedSiteRatingSummary()
|
|
64
|
+
.then(({ count, average }) => {
|
|
54
65
|
if (neededPresets.has("total_reviews")) {
|
|
55
|
-
resolvedPresets.total_reviews = String(
|
|
66
|
+
resolvedPresets.total_reviews = String(count);
|
|
56
67
|
}
|
|
57
|
-
if (neededPresets.has("platform_rating") &&
|
|
58
|
-
|
|
59
|
-
resolvedPresets.platform_rating = avg.toFixed(1) + "★";
|
|
68
|
+
if (neededPresets.has("platform_rating") && average !== null) {
|
|
69
|
+
resolvedPresets.platform_rating = average.toFixed(1) + "★";
|
|
60
70
|
}
|
|
61
71
|
})
|
|
62
72
|
.catch((err) => { void normalizeError(err); serverLogger.warn("live-stats: reviews/rating query failed — stat omitted", { error: err instanceof Error ? err.message : String(err) }); }));
|
|
@@ -4,7 +4,7 @@ import Image from "next/image";
|
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
import { Div, Row, Span } from "../../ui";
|
|
6
6
|
import { ROUNDED_MAP, SHADOW_MAP } from "../../ui/components/surface-tokens";
|
|
7
|
-
import { resolveMediaUrl } from "../../utils/media-url";
|
|
7
|
+
import { MEDIA_PROXY_PREFIX, resolveMediaUrl } from "../../utils/media-url";
|
|
8
8
|
const SIZE_HINTS = {
|
|
9
9
|
thumbnail: "(max-width: 640px) 80px, (max-width: 1024px) 96px, 112px",
|
|
10
10
|
card: "(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw",
|
|
@@ -32,6 +32,23 @@ const FALLBACK_ICONS = {
|
|
|
32
32
|
// icon, instead of failing permanently on the very first request.
|
|
33
33
|
const MAX_LOAD_RETRIES = 3;
|
|
34
34
|
const RETRY_BACKOFF_MS = [600, 1200, 2400];
|
|
35
|
+
/**
|
|
36
|
+
* Only `/media/<slug>` may be retried.
|
|
37
|
+
*
|
|
38
|
+
* 🛑 The retry appends `?retry=N`, which mints a BRAND-NEW CDN cache key that is
|
|
39
|
+
* guaranteed cold — so a failing image costs up to 4 cold function invocations
|
|
40
|
+
* instead of 1, on precisely the images that are already flaky. That is a real
|
|
41
|
+
* cost multiplier and it suspended this project once (HTTP 402, 2026-10-09).
|
|
42
|
+
*
|
|
43
|
+
* It is worth paying for our own Storage objects, because the post-upload race
|
|
44
|
+
* above is genuine and transient — retrying actually succeeds. It is NOT worth
|
|
45
|
+
* paying for `/api/media/ext`: there is no upload, the upstream is a third party
|
|
46
|
+
* we do not control, and an upstream that just failed will almost certainly fail
|
|
47
|
+
* again. Those fall straight through to the fallback icon on the first error.
|
|
48
|
+
*/
|
|
49
|
+
function isRetryableSrc(resolvedSrc) {
|
|
50
|
+
return resolvedSrc.startsWith(MEDIA_PROXY_PREFIX);
|
|
51
|
+
}
|
|
35
52
|
/** Resolve every URL in a comma-separated srcset string through the media proxy. */
|
|
36
53
|
function resolveSrcSet(input) {
|
|
37
54
|
return input
|
|
@@ -78,7 +95,7 @@ export function MediaImage({ src, alt, size = "card", priority = false, loading,
|
|
|
78
95
|
className ?? "",
|
|
79
96
|
].filter(Boolean).join(" ");
|
|
80
97
|
const handleError = () => {
|
|
81
|
-
if (retryCount < MAX_LOAD_RETRIES) {
|
|
98
|
+
if (resolvedSrc && isRetryableSrc(resolvedSrc) && retryCount < MAX_LOAD_RETRIES) {
|
|
82
99
|
retryTimeoutRef.current = setTimeout(() => {
|
|
83
100
|
setRetryCount((count) => count + 1);
|
|
84
101
|
}, RETRY_BACKOFF_MS[retryCount]);
|
|
@@ -15,15 +15,6 @@ export interface ProductListActionParams {
|
|
|
15
15
|
export type ProductListResult = FirebaseSieveResult<ProductDocument>;
|
|
16
16
|
export declare function listProducts(params?: ProductListActionParams): Promise<ProductListResult>;
|
|
17
17
|
export declare function getProductById(id: string): Promise<ProductDocument | null>;
|
|
18
|
-
/**
|
|
19
|
-
* Featured items for the homepage's generic "Featured Products" strip.
|
|
20
|
-
*
|
|
21
|
-
* Scoped to `GENERIC_PRODUCT_LISTING_TYPES` for the same reason `/products` is:
|
|
22
|
-
* the homepage already has dedicated Live Auctions, Pre-Orders and Prize Draws
|
|
23
|
-
* sections, so leaving this unscoped put the same item in two strips on one
|
|
24
|
-
* page — and an auction rendered as a generic product card shows Buy-Now chrome
|
|
25
|
-
* instead of bid affordances.
|
|
26
|
-
*/
|
|
27
18
|
export declare function getFeaturedProducts(pageSize?: number): Promise<ProductListResult>;
|
|
28
19
|
/**
|
|
29
20
|
* The soonest-ending LIVE auctions.
|
|
@@ -73,6 +73,28 @@ function toListResult(result, pageSize) {
|
|
|
73
73
|
* page — and an auction rendered as a generic product card shows Buy-Now chrome
|
|
74
74
|
* instead of bid affordances.
|
|
75
75
|
*/
|
|
76
|
+
/**
|
|
77
|
+
* How many documents a fixed-size homepage strip may read.
|
|
78
|
+
*
|
|
79
|
+
* 🛑 These strips land on `listPublicProducts`' in-memory window path — every
|
|
80
|
+
* anonymous viewer sets `hidesTestData`, which forces it — and that path pins
|
|
81
|
+
* the fetch to 50 documents regardless of the requested `pageSize`. So
|
|
82
|
+
* `getFeaturedProducts(12)` read 51 to render 12, and the homepage does this
|
|
83
|
+
* four times: ~200 reads for 44 cards, against a 50K-reads/day free tier that
|
|
84
|
+
* the project was exceeding 6× (2.1M/week, measured 2026-10-09).
|
|
85
|
+
*
|
|
86
|
+
* Headroom is genuinely required, not optional: availability and test-data are
|
|
87
|
+
* per-row predicates applied AFTER the fetch, so a window equal to `pageSize`
|
|
88
|
+
* would under-fill the strip the moment one row is unavailable. 2× with a floor
|
|
89
|
+
* of +8 absorbs that while still cutting the read roughly in half.
|
|
90
|
+
*
|
|
91
|
+
* Deliberately NOT derived inside `listPublicProducts` from `pageSize` — a
|
|
92
|
+
* browse page must keep the full 50 so it can page deeply, and silently
|
|
93
|
+
* shrinking its window would drop rows from page 2 onward.
|
|
94
|
+
*/
|
|
95
|
+
function featuredWindow(pageSize) {
|
|
96
|
+
return Math.max(pageSize * 2, pageSize + 8);
|
|
97
|
+
}
|
|
76
98
|
export async function getFeaturedProducts(pageSize = 8) {
|
|
77
99
|
return toListResult(await listPublicProducts({
|
|
78
100
|
featured: true,
|
|
@@ -81,6 +103,7 @@ export async function getFeaturedProducts(pageSize = 8) {
|
|
|
81
103
|
sorts: sortBy("createdAt", "DESC"),
|
|
82
104
|
page: 1,
|
|
83
105
|
pageSize,
|
|
106
|
+
windowSize: featuredWindow(pageSize),
|
|
84
107
|
}), pageSize);
|
|
85
108
|
}
|
|
86
109
|
/**
|
|
@@ -103,6 +126,7 @@ export async function getFeaturedAuctions(pageSize = 6) {
|
|
|
103
126
|
sorts: sortBy(PRODUCT_FIELDS.AUCTION_END_DATE, "ASC"),
|
|
104
127
|
page: 1,
|
|
105
128
|
pageSize,
|
|
129
|
+
windowSize: featuredWindow(pageSize),
|
|
106
130
|
}), pageSize);
|
|
107
131
|
}
|
|
108
132
|
export async function getLatestProducts(pageSize = 12) {
|
|
@@ -138,6 +162,7 @@ export async function getFeaturedPreOrders(pageSize = 6) {
|
|
|
138
162
|
sorts: sortBy(PRODUCT_FIELDS.PRE_ORDER_DELIVERY_DATE, "ASC"),
|
|
139
163
|
page: 1,
|
|
140
164
|
pageSize,
|
|
165
|
+
windowSize: featuredWindow(pageSize),
|
|
141
166
|
}), pageSize);
|
|
142
167
|
}
|
|
143
168
|
export async function getLatestPreOrders(pageSize = 12) {
|
|
@@ -20,5 +20,11 @@ export async function ArtStickersListView({ searchParams = {} }) {
|
|
|
20
20
|
// about the default view (Root Cause #30).
|
|
21
21
|
...defaultAvailabilityForListingTypes(ART_STICKERS_LISTING_TYPES),
|
|
22
22
|
}));
|
|
23
|
-
return (_jsxs(Main, { children: [_jsx(Section, { border: "bottom-subtle", paddingY: "b-md", padding: "t-xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "bold", color: "primary", children: "Art & Stickers" }), _jsx(Text, { className: "mt-1", color: "muted", size: "sm", children: "Original art prints and sticker packs from independent sellers" })] }) }), _jsxs(Container, { size: "xl", padding: "x-md", children: [_jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-4 mt-4" }), _jsx(ProductsIndexListing
|
|
23
|
+
return (_jsxs(Main, { children: [_jsx(Section, { border: "bottom-subtle", paddingY: "b-md", padding: "t-xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "bold", color: "primary", children: "Art & Stickers" }), _jsx(Text, { className: "mt-1", color: "muted", size: "sm", children: "Original art prints and sticker packs from independent sellers" })] }) }), _jsxs(Container, { size: "xl", padding: "x-md", children: [_jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-4 mt-4" }), _jsx(ProductsIndexListing
|
|
24
|
+
// `undefined`, not `null` — React Query treats null as defined data,
|
|
25
|
+
// so a failed query froze an empty grid. See ProductsIndexPageView.
|
|
26
|
+
, {
|
|
27
|
+
// `undefined`, not `null` — React Query treats null as defined data,
|
|
28
|
+
// so a failed query froze an empty grid. See ProductsIndexPageView.
|
|
29
|
+
initialData: result ?? undefined, listingTypes: ART_STICKERS_LISTING_TYPES, typeTabs: ART_STICKERS_TYPE_FILTER_TABS, searchPlaceholder: "Search art & stickers..." }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] })] }));
|
|
24
30
|
}
|
|
@@ -34,6 +34,11 @@ export async function PrizeDrawsSection({ config, }) {
|
|
|
34
34
|
sorts: sortBy(PRODUCT_FIELDS.CREATED_AT, "DESC"),
|
|
35
35
|
page: 1,
|
|
36
36
|
pageSize: config.maxItems ?? 8,
|
|
37
|
+
// Fixed-size strip that never pages — cap the bounded window instead of
|
|
38
|
+
// inheriting the 50-document default. Headroom over `pageSize` because
|
|
39
|
+
// availability and test-data are per-row predicates applied after the fetch.
|
|
40
|
+
// See `featuredWindow` in features/products/actions/product-actions.ts.
|
|
41
|
+
windowSize: Math.max((config.maxItems ?? 8) * 2, (config.maxItems ?? 8) + 8),
|
|
37
42
|
});
|
|
38
43
|
const draws = (result?.items ?? []);
|
|
39
44
|
if (draws.length === 0)
|
|
@@ -53,5 +53,5 @@ export async function ProductsIndexPageView({ searchParams = {} }) {
|
|
|
53
53
|
sorts: DEFAULT_SORT,
|
|
54
54
|
...defaultAvailabilityForListingTypes(effectiveTypes),
|
|
55
55
|
}));
|
|
56
|
-
return (_jsxs(Main, { children: [_jsx(Section, { border: "bottom-subtle", paddingY: "b-md", padding: "t-xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "bold", color: "primary", children: "Products" }), _jsx(Text, { className: "mt-1", color: "muted", size: "sm", children: "Discover amazing products and deals" }), _jsx(Div, { className: "mt-3", children: _jsx(TextLink, { variant: "bare", href: String(ROUTES.PUBLIC.AUCTIONS), rounded: "full", paddingX: "sm", paddingY: "2xs", size: "xs", weight: "medium", layout: "inline-flex", align: "center", gap: "sm", className: "border border-primary/30 bg-primary/10 text-primary-700 dark:text-primary-400 hover:bg-primary/15 transition-colors", children: "\uD83C\uDFF7\uFE0F Looking for unique deals? Browse Auctions \u2192" }) })] }) }), _jsxs(Container, { size: "xl", padding: "x-md", children: [_jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-4 mt-4" }), _jsx(ProductsIndexListing, { initialData: products }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] })] }));
|
|
56
|
+
return (_jsxs(Main, { children: [_jsx(Section, { border: "bottom-subtle", paddingY: "b-md", padding: "t-xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "bold", color: "primary", children: "Products" }), _jsx(Text, { className: "mt-1", color: "muted", size: "sm", children: "Discover amazing products and deals" }), _jsx(Div, { className: "mt-3", children: _jsx(TextLink, { variant: "bare", href: String(ROUTES.PUBLIC.AUCTIONS), rounded: "full", paddingX: "sm", paddingY: "2xs", size: "xs", weight: "medium", layout: "inline-flex", align: "center", gap: "sm", className: "border border-primary/30 bg-primary/10 text-primary-700 dark:text-primary-400 hover:bg-primary/15 transition-colors", children: "\uD83C\uDFF7\uFE0F Looking for unique deals? Browse Auctions \u2192" }) })] }) }), _jsxs(Container, { size: "xl", padding: "x-md", children: [_jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-4 mt-4" }), _jsx(ProductsIndexListing, { initialData: products ?? undefined }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] })] }));
|
|
57
57
|
}
|
|
@@ -42,6 +42,28 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
|
|
|
42
42
|
findApprovedByUser(userId: string): Promise<ReviewDocument[]>;
|
|
43
43
|
findPending(): Promise<ReviewDocument[]>;
|
|
44
44
|
findByStatus(status: ReviewStatus): Promise<ReviewDocument[]>;
|
|
45
|
+
/**
|
|
46
|
+
* How many approved reviews there are site-wide, and their mean rating.
|
|
47
|
+
*
|
|
48
|
+
* 🛑 Replaces `reviewRepository.findAll()` on the homepage render path, which
|
|
49
|
+
* was an **unbounded full-collection scan** whose entire output was a count
|
|
50
|
+
* and an average: two numbers, billed one read per document, on every cold
|
|
51
|
+
* render. It was the single largest Firestore read on the site and it existed
|
|
52
|
+
* to print `"35+"` and `"4.6★"`.
|
|
53
|
+
*
|
|
54
|
+
* A Firestore aggregation is billed at **one read per 1000 index entries
|
|
55
|
+
* matched**, so this is ~1 read at any catalogue size this project will see,
|
|
56
|
+
* and it is EXACT rather than a daily-stale rollup — which is why no scheduled
|
|
57
|
+
* job was added for it (the three sibling presets in `live-stats.ts` already
|
|
58
|
+
* use `count()` for the same reason).
|
|
59
|
+
*
|
|
60
|
+
* Returns `average: null` when there are no approved reviews, so the caller
|
|
61
|
+
* renders nothing rather than `0.0★` — an empty catalogue is not a bad rating.
|
|
62
|
+
*/
|
|
63
|
+
getApprovedSiteRatingSummary(): Promise<{
|
|
64
|
+
count: number;
|
|
65
|
+
average: number | null;
|
|
66
|
+
}>;
|
|
45
67
|
findFeatured(limit?: number): Promise<ReviewDocument[]>;
|
|
46
68
|
approve(reviewId: string, moderatorId: string, moderatorNote?: string): Promise<ReviewDocument>;
|
|
47
69
|
reject(reviewId: string, moderatorId: string, rejectionReason: string, moderatorNote?: string): Promise<ReviewDocument>;
|
|
@@ -130,6 +130,41 @@ class ReviewRepository extends BaseRepository {
|
|
|
130
130
|
async findByStatus(status) {
|
|
131
131
|
return this.findBy(REVIEW_FIELDS.STATUS, status);
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* How many approved reviews there are site-wide, and their mean rating.
|
|
135
|
+
*
|
|
136
|
+
* 🛑 Replaces `reviewRepository.findAll()` on the homepage render path, which
|
|
137
|
+
* was an **unbounded full-collection scan** whose entire output was a count
|
|
138
|
+
* and an average: two numbers, billed one read per document, on every cold
|
|
139
|
+
* render. It was the single largest Firestore read on the site and it existed
|
|
140
|
+
* to print `"35+"` and `"4.6★"`.
|
|
141
|
+
*
|
|
142
|
+
* A Firestore aggregation is billed at **one read per 1000 index entries
|
|
143
|
+
* matched**, so this is ~1 read at any catalogue size this project will see,
|
|
144
|
+
* and it is EXACT rather than a daily-stale rollup — which is why no scheduled
|
|
145
|
+
* job was added for it (the three sibling presets in `live-stats.ts` already
|
|
146
|
+
* use `count()` for the same reason).
|
|
147
|
+
*
|
|
148
|
+
* Returns `average: null` when there are no approved reviews, so the caller
|
|
149
|
+
* renders nothing rather than `0.0★` — an empty catalogue is not a bad rating.
|
|
150
|
+
*/
|
|
151
|
+
async getApprovedSiteRatingSummary() {
|
|
152
|
+
const base = this.getCollection().where(REVIEW_FIELDS.STATUS, "==", "approved");
|
|
153
|
+
const buckets = [];
|
|
154
|
+
for (let r = REVIEW_MIN_RATING; r <= REVIEW_MAX_RATING; r++)
|
|
155
|
+
buckets.push(r);
|
|
156
|
+
const counts = await Promise.all(buckets.map((rating) => getFirestoreCount(base.where(REVIEW_FIELDS.RATING, "==", rating))));
|
|
157
|
+
let count = 0;
|
|
158
|
+
let weighted = 0;
|
|
159
|
+
buckets.forEach((rating, i) => {
|
|
160
|
+
const n = counts[i] ?? 0;
|
|
161
|
+
count += n;
|
|
162
|
+
weighted += n * rating;
|
|
163
|
+
});
|
|
164
|
+
// null, not 0 — an empty catalogue has no rating, and rendering "0.0★" would
|
|
165
|
+
// state the opposite of the truth.
|
|
166
|
+
return { count, average: count > 0 ? weighted / count : null };
|
|
167
|
+
}
|
|
133
168
|
async findFeatured(limit = 18) {
|
|
134
169
|
const snapshot = await this.db
|
|
135
170
|
.collection(this.collection)
|
|
@@ -6,6 +6,7 @@ import { sortBy } from "@mohasinac/appkit/client";
|
|
|
6
6
|
import { useState, useCallback } from "react";
|
|
7
7
|
import { Badge, Button, Div, FilterChipGroup, RecordDetailModal, Span, Text, useToast } from "../../../ui";
|
|
8
8
|
import { buildBidDetailFields, bidStatusBadge } from "../../auctions/components/bid-detail-fields";
|
|
9
|
+
import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
|
|
9
10
|
import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
10
11
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
|
|
11
12
|
import { buildBulkAction } from "../../../_internal/shared/actions/bulk-helpers";
|
|
@@ -113,6 +114,9 @@ export function SellerBidsView({ endpoint = SELLER_ENDPOINTS.BIDS }) {
|
|
|
113
114
|
bidAmount: Number(item.bidAmount ?? 0),
|
|
114
115
|
status: toStringValue(item.status, "active"),
|
|
115
116
|
bidDate: toRelativeDate(item.bidDate ?? item.createdAt),
|
|
117
|
+
// The one cast, at the JSON -> document boundary where it belongs, so
|
|
118
|
+
// that every CONSUMER of `raw` typechecks for real.
|
|
119
|
+
raw: item,
|
|
116
120
|
})),
|
|
117
121
|
getTotal: (response, mappedRows) => (typeof response.total === "number" ? response.total : mappedRows.length),
|
|
118
122
|
buildFilters: (state) => (state.status ? sieveFilter("status", SIEVE_OP.EQ, state.status) : undefined),
|
|
@@ -145,8 +149,14 @@ export function SellerBidsView({ endpoint = SELLER_ENDPOINTS.BIDS }) {
|
|
|
145
149
|
},
|
|
146
150
|
};
|
|
147
151
|
const detailBadge = detail ? bidStatusBadge(detail.status) : null;
|
|
148
|
-
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(RecordDetailModal, { isOpen: detail !== null, onClose: () => setDetail(null), title: detail?.productTitle || "Bid", badges: detailBadge ? [{ label: detailBadge.label }] : undefined, fields: detail ? buildBidDetailFields(detail, "seller") : undefined, footer: detail ? (
|
|
152
|
+
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(RecordDetailModal, { isOpen: detail !== null, onClose: () => setDetail(null), title: detail?.productTitle || "Bid", badges: detailBadge ? [{ label: detailBadge.label }] : undefined, fields: detail ? buildBidDetailFields(detail.raw, "seller") : undefined, footer: detail ? (
|
|
149
153
|
// The full page — a modal cannot be linked, bookmarked or reloaded
|
|
150
154
|
// into, which is the whole reason the page exists.
|
|
151
|
-
_jsx(TextLink, { href: String(ROUTES.STORE.BID_DETAIL(detail.id)), children: "Open full page \u2192" })) : undefined
|
|
155
|
+
_jsx(TextLink, { href: String(ROUTES.STORE.BID_DETAIL(detail.id)), children: "Open full page \u2192" })) : undefined, extra:
|
|
156
|
+
// Present on the seller's full page and absent from this modal until
|
|
157
|
+
// 2026-10-04, which is a label the two surfaces disagreed about —
|
|
158
|
+
// exactly what the modal-vs-page case exists to catch. Mirrors
|
|
159
|
+
// AdminBidsView: absent history renders the empty label, never a
|
|
160
|
+
// step invented from `updatedAt`.
|
|
161
|
+
_jsx(RecordStatusTimeline, { entries: detail?.raw?.statusHistory, truncatedCount: detail?.raw?.statusHistoryTruncated }) })] }));
|
|
152
162
|
}
|
|
@@ -40,8 +40,20 @@ export const authored = {
|
|
|
40
40
|
"Read the current bid, the minimum increment and the bid panel.",
|
|
41
41
|
],
|
|
42
42
|
expectedBehaviour: "The twin of the guest case above — the bid figures and the working bid form are exactly what the gate must not cost a signed-in buyer.",
|
|
43
|
-
expectedUiState:
|
|
44
|
-
|
|
43
|
+
expectedUiState:
|
|
44
|
+
/*
|
|
45
|
+
* Literals corrected 2026-10-04 to the auction this case actually opens.
|
|
46
|
+
* They read '₹15,000.00' and a '₹1,000.00' increment, which are the
|
|
47
|
+
* numbers of a DIFFERENT fixture — `auction-money-flows-closing`, seeded
|
|
48
|
+
* by fixtures/money-flows__auction-win-to-payment.mjs at
|
|
49
|
+
* startingBid/currentBid 15000 with a 1000 increment, because its own
|
|
50
|
+
* case enters 16000 against it. This case's startPage is
|
|
51
|
+
* auction-beyblade-original-dragoon-storm, seeded startingBid 2999 /
|
|
52
|
+
* currentBid 3499 / buyNowPrice 5999, and the page renders exactly that.
|
|
53
|
+
* The product was never wrong; the expectation named another auction.
|
|
54
|
+
*/
|
|
55
|
+
"'Current bid ₹3,499.00' is shown with its bid count, a 'min increment ₹200.00' is stated, a Buy Now price of ₹5,999.00 is offered, and the bid panel carries a real amount input and a 'Place bid' submit button. Neither 'Sign in to see price' nor any other gate prompt appears anywhere on the page.",
|
|
56
|
+
expectedData: { currentBid: 3499, minIncrement: 200 },
|
|
45
57
|
endResult: "Nothing persists unless a bid is actually submitted, which this case does not do.",
|
|
46
58
|
},
|
|
47
59
|
"checklist-buying-product-detail-bundle-purchase": {
|
|
@@ -509,8 +509,8 @@ const rawTesterChecklistItems = [
|
|
|
509
509
|
},
|
|
510
510
|
{
|
|
511
511
|
key: "seed-images-are-labelled-tiles",
|
|
512
|
-
label: "Every seeded product image is a
|
|
513
|
-
description: "
|
|
512
|
+
label: "Every seeded product image is a coloured local tile — NOT a broken-image icon, and NOT all the same colour",
|
|
513
|
+
description: "Updated 2026-10-09. BEFORE: seeded images were fetched from a third party through /api/media/ext — one Node lambda plus a sharp re-encode PER IMAGE, 160 of them on the homepage alone. That is what exceeded the Vercel Hobby caps and suspended the site (HTTP 402). AFTER: `seedPhoto()` returns a static local file under `/images/seed-tiles/<hex>.svg`, so a seeded image costs no function and no upstream request. Open a product grid and confirm each card shows a coloured tile with a faint ring motif. Two checks that matter: view-source on a card image and confirm the src starts with `/images/seed-tiles/` and NOT `/api/media/ext`; and confirm the tiles are not all one colour (the colour is hashed per item, so a grid should show several). NOTE: tiles deliberately no longer render the item's NAME — that text cost a function invocation per card to draw, and the card already displays the title beside the image.", // audit-hardcoded-api-routes-ok: prose, not a call site — the tester must be told the exact string to look for in an image src, and "the external-media proxy route" is not checkable
|
|
514
514
|
href: "/products",
|
|
515
515
|
},
|
|
516
516
|
{
|
|
@@ -17,4 +17,4 @@ export declare function seedPhotoLabel(seed: string): string;
|
|
|
17
17
|
* `appkit-seed` invocation, so anything non-stable here would give the same
|
|
18
18
|
* fixture a different URL on every run.
|
|
19
19
|
*/
|
|
20
|
-
export declare function seedPhoto(seed: string,
|
|
20
|
+
export declare function seedPhoto(seed: string, _width?: number, _height?: number): string;
|
|
@@ -47,16 +47,49 @@ export function seedExtMedia(url) {
|
|
|
47
47
|
* photography for a demo catalogue: every card now says what it is, and nobody
|
|
48
48
|
* can mistake seeded data for real inventory.
|
|
49
49
|
*/
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
/*
|
|
51
|
+
* 🛑 SUPERSEDED 2026-10-09 — seed photography is now LOCAL and costs nothing.
|
|
52
|
+
*
|
|
53
|
+
* The reasoning above is still correct about third-party fragility, and the
|
|
54
|
+
* one-line-to-move property is still the goal. What it missed is the cost: every
|
|
55
|
+
* one of these URLs was persisted as `/api/media/ext?url=…`, so every render of
|
|
56
|
+
* every card was a Node lambda doing a third-party fetch (up to 2 × 4 s) plus a
|
|
57
|
+
* full sharp decode/watermark/encode — ONE INVOCATION PER IMAGE. The homepage
|
|
58
|
+
* alone referenced 160 of them at ~49.5 KB; `52.52 GB of origin transfer ÷
|
|
59
|
+
* 49.5 KB ≈ 1.06 M` proxy responses in a week, which suspended the project
|
|
60
|
+
* (HTTP 402).
|
|
61
|
+
*
|
|
62
|
+
* A placeholder is synthetic by definition, so there was never anything to
|
|
63
|
+
* fetch. Six static tiles ship in `public/images/seed-tiles/`, chosen by the
|
|
64
|
+
* same deterministic hash, and the homepage goes 160 images → 6.
|
|
65
|
+
*
|
|
66
|
+
* The trade accepted: the tile no longer renders the item's NAME. That text was
|
|
67
|
+
* a genuine gain and it is lost here — but it cost a function invocation per
|
|
68
|
+
* card to render text the card already displays beside the image, and `alt` still
|
|
69
|
+
* carries it for assistive tech. `seedPhotoLabel` is kept and still exported for
|
|
70
|
+
* exactly that use.
|
|
71
|
+
*
|
|
72
|
+
* 🛑 Do NOT reintroduce a third-party host here, and do NOT encode width/height
|
|
73
|
+
* into the path — a per-size URL is a per-size CDN cache key, which is how 6
|
|
74
|
+
* files become 400 again. `audit-media-proxy-hosts` blocks the first; this
|
|
75
|
+
* comment is the only thing guarding the second.
|
|
76
|
+
*/
|
|
77
|
+
const SEED_TILE_PREFIX = "/images/seed-tiles/";
|
|
52
78
|
/**
|
|
53
79
|
* Muted backgrounds, all dark enough for the same light foreground.
|
|
54
80
|
*
|
|
55
|
-
* Raw hex rather than theme tokens because
|
|
56
|
-
*
|
|
81
|
+
* Raw hex rather than theme tokens because an SVG served as the body of an
|
|
82
|
+
* `<img>` has no stylesheet and no `var(--appkit-color-*)` in scope — the same
|
|
83
|
+
* reason `_placeholder.ts` keeps its artwork in an asset file.
|
|
84
|
+
*
|
|
85
|
+
* 🛑 These six values ARE the filenames in `public/images/seed-tiles/`, and they
|
|
86
|
+
* are mirrored a third time in `PLACEHOLDER_MEDIA_HOSTS`' sibling
|
|
87
|
+
* `SEED_TILE_COLOURS` (`appkit/src/utils/media-url.ts`), which maps a legacy
|
|
88
|
+
* stored `placehold.co` URL back to the same tile. Add a colour and all three
|
|
89
|
+
* must move together; a missing one degrades to a deterministic fallback tile
|
|
90
|
+
* rather than a broken image, so the failure mode is "less variety", never a 404.
|
|
57
91
|
*/
|
|
58
92
|
const SEED_PHOTO_COLOURS = ["1e293b", "334155", "3f3f46", "44403c", "312e81", "164e63"];
|
|
59
|
-
const SEED_PHOTO_FG = "f8fafc";
|
|
60
93
|
/** Deterministic 32-bit hash. Same seed, same colour, every reseed. */
|
|
61
94
|
function seedHash(input) {
|
|
62
95
|
let h = 2166136261;
|
|
@@ -95,8 +128,11 @@ export function seedPhotoLabel(seed) {
|
|
|
95
128
|
* `appkit-seed` invocation, so anything non-stable here would give the same
|
|
96
129
|
* fixture a different URL on every run.
|
|
97
130
|
*/
|
|
98
|
-
export function seedPhoto(seed,
|
|
131
|
+
export function seedPhoto(seed, _width, _height) {
|
|
99
132
|
const bg = SEED_PHOTO_COLOURS[seedHash(seed) % SEED_PHOTO_COLOURS.length];
|
|
100
|
-
|
|
101
|
-
|
|
133
|
+
// Width/height are accepted and ignored. All 366 call sites pass them, and the
|
|
134
|
+
// tiles carry `preserveAspectRatio="xMidYMid slice"` so one file fits every
|
|
135
|
+
// aspect — baking the dimensions into the path would mint a separate cache
|
|
136
|
+
// entry per size, which is the thing this change removes.
|
|
137
|
+
return `${SEED_TILE_PREFIX}${bg}.svg`;
|
|
102
138
|
}
|
|
@@ -67,9 +67,11 @@ const daysAgo = (n) => new Date(NOW.getTime() - n * 86400000);
|
|
|
67
67
|
* authored against one day's ids and could never resolve afterwards.
|
|
68
68
|
*/
|
|
69
69
|
const SEED_EPOCH = new Date("2026-01-01T00:00:00.000Z");
|
|
70
|
-
// Deterministic per-product thumbnail —
|
|
71
|
-
//
|
|
72
|
-
// own image, just be present and consistent across reseeds.
|
|
70
|
+
// Deterministic per-product thumbnail — `seedPhoto` maps a seed string to one
|
|
71
|
+
// of six LOCAL tiles, so this doesn't need to match the real product seed's
|
|
72
|
+
// own image, just be present and consistent across reseeds. (It used to name
|
|
73
|
+
// picsum.photos here; seeded imagery stopped being a third-party fetch on
|
|
74
|
+
// 2026-10-09 — see seedPhoto's header.) Backfills the
|
|
73
75
|
// `image`/`imageUrls` fields real checkout orders only started carrying
|
|
74
76
|
// 2026-08-20 (see OrderDocumentItem.image), so seeded order fixtures show a
|
|
75
77
|
// thumbnail on My Orders / order-detail like real orders do.
|
|
@@ -55,6 +55,27 @@ const _rawproductsLiveItemsSeedData = [
|
|
|
55
55
|
seedPhoto("live-image-golden-retriever-1-20260816", 900, 900),
|
|
56
56
|
seedPhoto("live-image-golden-retriever-2-20260816", 900, 900),
|
|
57
57
|
],
|
|
58
|
+
/*
|
|
59
|
+
* 🛑 A LIVE LISTING WITHOUT A VIDEO IS NOT A LISTING THE PRODUCT WOULD ACCEPT.
|
|
60
|
+
*
|
|
61
|
+
* `/store/live/new` refuses to save one — "a buyer purchasing an animal or
|
|
62
|
+
* plant needs to see it move" — so a seeded live fixture carrying only
|
|
63
|
+
* stills is data no seller could have created through the form, and every
|
|
64
|
+
* case written against the real creation flow then asserts against a
|
|
65
|
+
* fixture that contradicts it. Found 2026-10-04 driving
|
|
66
|
+
* `live-item-detail`, whose expectation that the gallery strip offers a
|
|
67
|
+
* "View video" entry was correct about the product and wrong about the
|
|
68
|
+
* data: the page shipped `video: undefined` and rendered 2 image slides.
|
|
69
|
+
*
|
|
70
|
+
* `url` stays RAW and unwrapped (Root Cause #27) — the media proxy is
|
|
71
|
+
* image-only and 400s on video/mp4 — while `thumbnailUrl` is wrapped,
|
|
72
|
+
* because a poster frame IS an image and should carry the watermark.
|
|
73
|
+
*/
|
|
74
|
+
video: {
|
|
75
|
+
url: "/demo-media/sample-video.mp4",
|
|
76
|
+
thumbnailUrl: seedPhoto("live-video-thumb-golden-retriever-20261004", 800, 450),
|
|
77
|
+
duration: 15,
|
|
78
|
+
},
|
|
58
79
|
status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
|
|
59
80
|
condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
|
|
60
81
|
listingType: "live",
|
|
@@ -102,6 +123,12 @@ const _rawproductsLiveItemsSeedData = [
|
|
|
102
123
|
isSold: false,
|
|
103
124
|
mainImage: seedPhoto("live-image-bearded-dragon-1-20260817", 900, 900),
|
|
104
125
|
images: [seedPhoto("live-image-bearded-dragon-1-20260817", 900, 900)],
|
|
126
|
+
// Required for a live listing — see the note on the retriever above.
|
|
127
|
+
video: {
|
|
128
|
+
url: "/demo-media/sample-video.mp4",
|
|
129
|
+
thumbnailUrl: seedPhoto("live-video-thumb-bearded-dragon-20261004", 800, 450),
|
|
130
|
+
duration: 15,
|
|
131
|
+
},
|
|
105
132
|
status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
|
|
106
133
|
condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
|
|
107
134
|
listingType: "live",
|
|
@@ -153,6 +180,12 @@ const _rawproductsLiveItemsSeedData = [
|
|
|
153
180
|
seedPhoto("live-image-bonsai-juniper-1-20260818", 900, 900),
|
|
154
181
|
seedPhoto("live-image-bonsai-juniper-2-20260818", 900, 900),
|
|
155
182
|
],
|
|
183
|
+
// Required for a live listing — see the note on the retriever above.
|
|
184
|
+
video: {
|
|
185
|
+
url: "/demo-media/sample-video.mp4",
|
|
186
|
+
thumbnailUrl: seedPhoto("live-video-thumb-bonsai-juniper-20261004", 800, 450),
|
|
187
|
+
duration: 15,
|
|
188
|
+
},
|
|
156
189
|
status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
|
|
157
190
|
condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
|
|
158
191
|
listingType: "live",
|
|
@@ -203,6 +236,15 @@ const _rawproductsLiveItemsSeedData = [
|
|
|
203
236
|
images: [
|
|
204
237
|
seedPhoto("live-image-juniper-bonsai-sold-1-20260824", 900, 900),
|
|
205
238
|
],
|
|
239
|
+
// Required for a live listing — see the note on the retriever above. A SOLD
|
|
240
|
+
// fixture needs it just as much: it is the row the "Sold & Ended" scope
|
|
241
|
+
// renders, and a sold listing that could never have been created is no
|
|
242
|
+
// more representative than an available one.
|
|
243
|
+
video: {
|
|
244
|
+
url: "/demo-media/sample-video.mp4",
|
|
245
|
+
thumbnailUrl: seedPhoto("live-video-thumb-juniper-bonsai-sold-20261004", 800, 450),
|
|
246
|
+
duration: 15,
|
|
247
|
+
},
|
|
206
248
|
status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
|
|
207
249
|
condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
|
|
208
250
|
listingType: "live",
|
package/dist/server.d.ts
CHANGED
|
@@ -536,6 +536,8 @@ export type { SitemapOptions, RobotsOptions, ManifestOptions, DefaultOgOptions }
|
|
|
536
536
|
export { checkActionAllowed } from "./features/admin/utils/checkActionAllowed";
|
|
537
537
|
export { getDisabledRoutes } from "./features/admin/utils/getDisabledRoutes";
|
|
538
538
|
export { getSiteSettingsGlobal } from "./features/admin/utils/getSiteSettingsGlobal";
|
|
539
|
+
export { getHomepageFaqsCached } from "./features/faq/utils/getHomepageFaqsCached";
|
|
540
|
+
export { revalidateTargetsFor, type RevalidateHints, } from "./_internal/shared/cache/revalidate-targets";
|
|
539
541
|
export { getSiteSettingsSafe } from "./_internal/server/features/site-settings/safe-settings";
|
|
540
542
|
export { toPublicSiteSettings, PUBLIC_SITE_SETTINGS_FIELDS, PRIVATE_SITE_SETTINGS_FIELDS, type ToPublicSiteSettingsOptions, } from "./_internal/server/features/site-settings/adapters";
|
|
541
543
|
export { callFirebaseIdentityToolkit } from "./_internal/server/features/auth/firebase-identity";
|
package/dist/server.js
CHANGED
|
@@ -1404,6 +1404,12 @@ export { buildSitemap, buildRobots, buildManifest, buildDefaultOgImage, DEFAULT_
|
|
|
1404
1404
|
export { checkActionAllowed } from "./features/admin/utils/checkActionAllowed";
|
|
1405
1405
|
export { getDisabledRoutes } from "./features/admin/utils/getDisabledRoutes";
|
|
1406
1406
|
export { getSiteSettingsGlobal } from "./features/admin/utils/getSiteSettingsGlobal";
|
|
1407
|
+
// Same React.cache dedup, for the FAQ list the homepage reads twice per render
|
|
1408
|
+
// (once for the section, once for its JSON-LD).
|
|
1409
|
+
export { getHomepageFaqsCached } from "./features/faq/utils/getHomepageFaqsCached";
|
|
1410
|
+
// Which PAGE paths a write makes stale — the input to revalidatePath(). Long
|
|
1411
|
+
// detail-route TTLs are only safe because this exists.
|
|
1412
|
+
export { revalidateTargetsFor, } from "./_internal/shared/cache/revalidate-targets";
|
|
1407
1413
|
// Null-safe wrapper — returns null on Firestore failure instead of throwing.
|
|
1408
1414
|
export { getSiteSettingsSafe } from "./_internal/server/features/site-settings/safe-settings";
|
|
1409
1415
|
// Public site-settings allow-list projection — the ONLY sanctioned shape for
|
|
@@ -30,6 +30,32 @@ export declare const MEDIA_URL_MAX_LENGTH = 2048;
|
|
|
30
30
|
*/
|
|
31
31
|
export declare const APPROVED_MEDIA_DOMAINS: readonly string[];
|
|
32
32
|
export declare const MEDIA_URL_MESSAGE = "Must be a stored media reference (/media/<slug>) or a URL on an approved CDN domain";
|
|
33
|
+
/**
|
|
34
|
+
* Local, static stand-ins for seed photography. Served straight out of
|
|
35
|
+
* `public/` — no function, no upstream fetch, no sharp pipeline.
|
|
36
|
+
*
|
|
37
|
+
* 🛑 This is the single most load-bearing cost control in the app. Every seed
|
|
38
|
+
* image used to resolve to `/api/media/ext?url=https://placehold.co/…`, i.e. a
|
|
39
|
+
* Node lambda that fetched a third party (up to 2 × 4 s) and ran a full sharp
|
|
40
|
+
* decode/watermark/encode — ONE INVOCATION PER IMAGE. Measured 2026-10-09: the
|
|
41
|
+
* homepage referenced **160** such images at ~49.5 KB each, and
|
|
42
|
+
* `52.52 GB of Fast Origin Transfer ÷ 49.5 KB ≈ 1.06 M` proxy responses in a
|
|
43
|
+
* week. That is what suspended the project (HTTP 402).
|
|
44
|
+
*
|
|
45
|
+
* Six tiles, because the stored URL already encodes which of the six
|
|
46
|
+
* `SEED_PHOTO_COLOURS` it was generated with — so the mapping needs no new
|
|
47
|
+
* data and no migration. The homepage goes 160 images → 6.
|
|
48
|
+
*/
|
|
49
|
+
export declare const SEED_TILE_PREFIX = "/images/seed-tiles/";
|
|
50
|
+
/**
|
|
51
|
+
* A local tile for `absUrl` when it points at a placeholder host, else undefined.
|
|
52
|
+
*
|
|
53
|
+
* placehold.co bakes its colours into the path (`/{w}x{h}/{bg}/{fg}/png`), so the
|
|
54
|
+
* first 6-hex segment IS the background the fixture was generated with — the
|
|
55
|
+
* size segment contains an `x` and cannot match, and the foreground comes after
|
|
56
|
+
* the background, so `.find()` picks the right one.
|
|
57
|
+
*/
|
|
58
|
+
export declare function placeholderTileFor(absUrl: string): string | undefined;
|
|
33
59
|
/**
|
|
34
60
|
* Is `value` something we are willing to PERSIST as a media reference?
|
|
35
61
|
*
|