@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
@@ -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, faqsRepository, siteSettingsRepository } from "../../../repositories";
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
- const siteSettings = await safeRead(() => siteSettingsRepository.getSingleton(), {
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
- safeRead(() => faqsRepository.getHomepageFAQs(), {
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(reviewRepository.findAll()
51
- .then((res) => {
52
- const all = Array.isArray(res) ? res : (res?.data ?? []);
53
- const approved = all.filter((r) => !r.status || r.status === "approved");
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(approved.length);
66
+ resolvedPresets.total_reviews = String(count);
56
67
  }
57
- if (neededPresets.has("platform_rating") && approved.length > 0) {
58
- const avg = approved.reduce((sum, r) => sum + (r.rating ?? 0), 0) / approved.length;
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, { initialData: result ?? null, listingTypes: ART_STICKERS_LISTING_TYPES, typeTabs: ART_STICKERS_TYPE_FILTER_TABS, searchPlaceholder: "Search art & stickers..." }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] })] }));
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)
@@ -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 readable tile showing the item's own name — NOT a broken-image icon and NOT an empty grey box",
513
- description: "Fixed 2026-08-31. BEFORE: 409 of the ~437 seeded image URLs pointed at picsum.photos, which went down (503 from its origin AND its CDN), so nearly every image on the site returned 502 and rendered as the browser's broken-image icon. AFTER: images come from `seedPhoto()`, which names its host in ONE place and renders the item's name on the tile. Open a product grid and confirm the tiles show text like \"Dranzer Phoenix\" rather than broken icons. A grid of identical blank tiles is also a fail — each should differ.",
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, width: number, height: number): 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
- /** The one place a seed image host is named. Changing it is a one-line edit. */
51
- const SEED_PHOTO_HOST = "https://placehold.co";
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 these are baked into a URL that a
56
- * third party renders — no stylesheet and no CSS custom property is in scope.
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, width, height) {
131
+ export function seedPhoto(seed, _width, _height) {
99
132
  const bg = SEED_PHOTO_COLOURS[seedHash(seed) % SEED_PHOTO_COLOURS.length];
100
- const text = encodeURIComponent(seedPhotoLabel(seed));
101
- return seedExtMedia(`${SEED_PHOTO_HOST}/${width}x${height}/${bg}/${SEED_PHOTO_FG}/png?text=${text}`);
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 — picsum.photos returns a stable image
71
- // for any seed string, so this doesn't need to match the real product seed's
72
- // own image, just be present and consistent across reseeds. Backfills the
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.
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
  *