@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.
Files changed (53) 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/seller/components/SellerBidsView.js +12 -2
  43. package/dist/features/tester/seed-data/authored/buying__product-detail.js +14 -2
  44. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +2 -2
  45. package/dist/seed/_helpers/media.d.ts +1 -1
  46. package/dist/seed/_helpers/media.js +44 -8
  47. package/dist/seed/orders-seed-data.js +5 -3
  48. package/dist/seed/products-live-items-seed-data.js +42 -0
  49. package/dist/server.d.ts +2 -0
  50. package/dist/server.js +6 -0
  51. package/dist/utils/media-url.d.ts +26 -0
  52. package/dist/utils/media-url.js +144 -0
  53. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { Stack, Row, Text, Heading, Badge, Section, Container, Div, Anchor, } from "../../../../ui";
5
5
  import { MediaImage } from "../../../../features/media/MediaImage";
6
+ import { useOptionalSession } from "../../../../react/contexts/SessionContext";
6
7
  import { ROUTES } from "../../../../next/routing/route-map";
7
8
  import { LotterySlotGrid } from "./LotterySlotGrid";
8
9
  import { LotteryPullForm } from "./LotteryPullForm";
@@ -13,10 +14,30 @@ import { slotsToCollageItems } from "./slot-collage-items";
13
14
  * Shows cover + description + slot grid + pull form (if active + logged in).
14
15
  */
15
16
  export function LotteryDetailView({ event, user, currentEntry }) {
17
+ /*
18
+ * ๐Ÿ›‘ Viewer identity is read on the CLIENT, not taken from the `user` prop.
19
+ *
20
+ * The page used to `await getServerSessionUser()` to supply that prop, which
21
+ * cost twice over: `cookies()` forced dynamic rendering, so the route's
22
+ * `revalidate = 30` was silently inert (Root Cause #94) and every visit paid a
23
+ * full render; and the HTML then varied per viewer, so it could never be
24
+ * cached or shared even once the TTL was fixed.
25
+ *
26
+ * `useOptionalSession` โ€” not `useSession` โ€” because library code that adjusts
27
+ * for the viewer must not require a provider to be mounted (Root Cause #20);
28
+ * absent, it fails CLOSED to the signed-out branch.
29
+ *
30
+ * The `user` prop is kept as an optional SEED so a caller that genuinely has
31
+ * the viewer (an authenticated dashboard mount) still renders correctly on
32
+ * first paint.
33
+ */
34
+ const session = useOptionalSession();
35
+ const isAuthResolving = session?.loading ?? false;
36
+ const viewer = session?.user ?? user ?? null;
16
37
  const [pulledEntry, setPulledEntry] = useState(currentEntry?.assignedPrizeSlotNumber != null ? currentEntry : null);
17
38
  const config = event.lotteryConfig;
18
39
  const prizeItems = slotsToCollageItems(config?.slots ?? []);
19
40
  const isActive = event.status === "active";
20
41
  const isEnded = event.status === "ended" || event.status === "cancelled";
21
- return (_jsx(Container, { children: _jsx(Section, { children: _jsxs(Stack, { gap: "xl", children: [event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { rounded: "2xl", className: "relative overflow-hidden", style: { aspectRatio: "16/9", maxHeight: "16rem" }, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: event.title, size: "hero" }) })) : (_jsx(Div, { rounded: "2xl", surface: "subtle", padding: "xl", align: "center", justify: "center", children: _jsx(Text, { size: "5xl", children: "\uD83C\uDFB0" }) })), _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, children: [_jsx(Badge, { variant: isActive ? "success" : isEnded ? "danger" : "warning", children: event.status }), config && (_jsxs(Badge, { variant: "info", children: [config.slots.filter((s) => s.isBooked).length, "/", config.totalSlots, " slots claimed"] }))] }), _jsx(Heading, { level: 1, size: "3xl", weight: "bold", children: event.title }), event.description ? (_jsx(Text, { color: "muted", size: "sm", children: event.description.replace(/<[^>]+>/g, "").slice(0, 200) })) : null, event.endsAt ? (_jsxs(Text, { size: "sm", color: "muted", children: ["Ends: ", new Date(event.endsAt).toLocaleDateString("en-IN", { dateStyle: "long" })] })) : null] }), prizeItems.length > 0 ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Prizes" }), _jsx(PrizeDrawCollage, { items: prizeItems, wonLabel: "Claimed" })] })) : null, config && config.slots.length > 0 ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Slots" }), _jsx(LotterySlotGrid, { slots: config.slots, totalSlots: config.totalSlots })] })) : null, isActive && config && (_jsx(Div, { padding: "md", rounded: "2xl", surface: "card", className: "border border-[var(--appkit-color-border)]", children: !user ? (_jsxs(Stack, { gap: "md", className: "text-center", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Want to Participate?" }), _jsx(Text, { color: "muted", children: "Log in to submit your lottery entry." }), _jsx(Anchor, { href: String(ROUTES.AUTH.LOGIN), tone: "brand", children: "Log In to Enter" })] })) : pulledEntry ? (_jsxs(Stack, { gap: "sm", surface: "success-surface", padding: "md", rounded: "xl", className: "border border-success/20", children: [_jsxs(Heading, { level: 2, size: "lg", weight: "bold", className: "text-success", children: ["You're Lottery #", pulledEntry.userLotteryNumber, "!"] }), _jsxs(Text, { className: "text-success", size: "sm", children: ["Assigned to Slot #", pulledEntry.assignedPrizeSlotNumber, pulledEntry.slotName ? ` โ€” ${pulledEntry.slotName}` : "", "."] })] })) : (_jsx(LotteryPullForm, { sourceType: "event", eventId: event.id, totalSlots: config.totalSlots, maxPullsPerUser: config.maxPullsPerUser, onSuccess: (res) => setPulledEntry(res) })) })), isEnded && !pulledEntry && (_jsx(Stack, { gap: "sm", surface: "muted", padding: "md", rounded: "xl", children: _jsx(Text, { color: "muted", weight: "semibold", children: "This lottery has ended." }) }))] }) }) }));
42
+ return (_jsx(Container, { children: _jsx(Section, { children: _jsxs(Stack, { gap: "xl", children: [event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { rounded: "2xl", className: "relative overflow-hidden", style: { aspectRatio: "16/9", maxHeight: "16rem" }, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: event.title, size: "hero" }) })) : (_jsx(Div, { rounded: "2xl", surface: "subtle", padding: "xl", align: "center", justify: "center", children: _jsx(Text, { size: "5xl", children: "\uD83C\uDFB0" }) })), _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, children: [_jsx(Badge, { variant: isActive ? "success" : isEnded ? "danger" : "warning", children: event.status }), config && (_jsxs(Badge, { variant: "info", children: [config.slots.filter((s) => s.isBooked).length, "/", config.totalSlots, " slots claimed"] }))] }), _jsx(Heading, { level: 1, size: "3xl", weight: "bold", children: event.title }), event.description ? (_jsx(Text, { color: "muted", size: "sm", children: event.description.replace(/<[^>]+>/g, "").slice(0, 200) })) : null, event.endsAt ? (_jsxs(Text, { size: "sm", color: "muted", children: ["Ends: ", new Date(event.endsAt).toLocaleDateString("en-IN", { dateStyle: "long" })] })) : null] }), prizeItems.length > 0 ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Prizes" }), _jsx(PrizeDrawCollage, { items: prizeItems, wonLabel: "Claimed" })] })) : null, config && config.slots.length > 0 ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Slots" }), _jsx(LotterySlotGrid, { slots: config.slots, totalSlots: config.totalSlots })] })) : null, isActive && config && (_jsx(Div, { padding: "md", rounded: "2xl", surface: "card", className: "border border-[var(--appkit-color-border)]", children: isAuthResolving ? (_jsx(Stack, { gap: "md", className: "text-center", children: _jsx(Text, { color: "muted", children: "Checking your session\u2026" }) })) : !viewer ? (_jsxs(Stack, { gap: "md", className: "text-center", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: "Want to Participate?" }), _jsx(Text, { color: "muted", children: "Log in to submit your lottery entry." }), _jsx(Anchor, { href: String(ROUTES.AUTH.LOGIN), tone: "brand", children: "Log In to Enter" })] })) : pulledEntry ? (_jsxs(Stack, { gap: "sm", surface: "success-surface", padding: "md", rounded: "xl", className: "border border-success/20", children: [_jsxs(Heading, { level: 2, size: "lg", weight: "bold", className: "text-success", children: ["You're Lottery #", pulledEntry.userLotteryNumber, "!"] }), _jsxs(Text, { className: "text-success", size: "sm", children: ["Assigned to Slot #", pulledEntry.assignedPrizeSlotNumber, pulledEntry.slotName ? ` โ€” ${pulledEntry.slotName}` : "", "."] })] })) : (_jsx(LotteryPullForm, { sourceType: "event", eventId: event.id, totalSlots: config.totalSlots, maxPullsPerUser: config.maxPullsPerUser, onSuccess: (res) => setPulledEntry(res) })) })), isEnded && !pulledEntry && (_jsx(Stack, { gap: "sm", surface: "muted", padding: "md", rounded: "xl", children: _jsx(Text, { color: "muted", weight: "semibold", children: "This lottery has ended." }) }))] }) }) }));
22
43
  }
@@ -90,6 +90,30 @@ export interface PublicProductListInput {
90
90
  pageSize?: number;
91
91
  sorts?: string;
92
92
  cursor?: string | null;
93
+ /**
94
+ * How many documents the BOUNDED-WINDOW path may fetch. Defaults to
95
+ * `PUBLIC_PRODUCT_MAX_PAGE_SIZE` (50) โ€” today's behaviour for every caller
96
+ * that omits it.
97
+ *
98
+ * ๐Ÿ›‘ Only meaningful when the query lands on the in-memory path, which is
99
+ * almost always: `hidesTestData` is true for EVERY anonymous viewer, so
100
+ * `inMemory` is true for essentially all public browsing. On that path the
101
+ * fetch is pinned to `page: 1, pageSize: 50` regardless of the caller's
102
+ * `pageSize`, because the filter is a per-row predicate applied after the
103
+ * fetch and before the slice.
104
+ *
105
+ * That is correct for a browse page, which must be able to page deeply. It is
106
+ * wasteful for a FIXED-SIZE homepage strip: `getFeaturedProducts(12)` read 51
107
+ * documents to render 12, and the homepage did that four times โ€” ~200 reads
108
+ * for 44 cards, on a 50K-reads/day free tier (measured 2.1M/week).
109
+ *
110
+ * Set it only where the caller genuinely wants N items and will never page:
111
+ * a larger value than `pageSize` is still needed as headroom, because rows are
112
+ * dropped by the predicate after the fetch. Too small a window silently yields
113
+ * fewer items than asked for โ€” which is why this is opt-in rather than derived
114
+ * from `pageSize`, and why `truncated` still reports honestly below.
115
+ */
116
+ windowSize?: number;
93
117
  }
94
118
  export interface PublicProductListResult {
95
119
  items: Row[];
@@ -607,11 +607,14 @@ export async function listPublicProducts(input, opts) {
607
607
  : hasTypeFacet
608
608
  ? FACET_FETCH_SORT
609
609
  : sorts;
610
+ // Clamped to the 50-doc ceiling so an opt-in can only ever make the window
611
+ // SMALLER โ€” a caller cannot widen the public read budget through this door.
612
+ const windowSize = Math.min(Math.max(1, input.windowSize ?? PUBLIC_PRODUCT_MAX_PAGE_SIZE), PUBLIC_PRODUCT_MAX_PAGE_SIZE);
610
613
  const result = await runQuery(executor, {
611
614
  filters,
612
615
  sorts: fetchSorts,
613
616
  page: inMemory ? 1 : page,
614
- pageSize: inMemory ? PUBLIC_PRODUCT_MAX_PAGE_SIZE : pageSize,
617
+ pageSize: inMemory ? windowSize : pageSize,
615
618
  cursor: inMemory ? null : (input.cursor ?? null),
616
619
  search: input.q,
617
620
  });
@@ -625,7 +628,12 @@ export async function listPublicProducts(input, opts) {
625
628
  let cursor = result.cursor ?? null;
626
629
  let truncated = false;
627
630
  if (inMemory) {
628
- truncated = items.length >= PUBLIC_PRODUCT_MAX_PAGE_SIZE;
631
+ // Against `windowSize`, NOT the 50 ceiling. A caller that opted into a
632
+ // smaller window saturates it at that size, so comparing with 50 would
633
+ // report `truncated: false` for a window that was in fact full โ€” and
634
+ // `truncated` is what makes `total` render as "50+" and keeps the pager
635
+ // offering Next instead of claiming a false last page.
636
+ truncated = items.length >= windowSize;
629
637
  /** OR semantics, matching the single-value `array-contains` pushdown. */
630
638
  const matchesAny = (raw, wanted) => {
631
639
  if (!Array.isArray(raw))
@@ -1,3 +1,4 @@
1
+ import { notifyRevalidate } from "./revalidateNotify";
1
2
  import { normalizeError } from "../../../../errors/normalize";
2
3
  /**
3
4
  * Core: maintain DFS `position` + `subtreeSize` on every category write.
@@ -87,6 +88,10 @@ async function getMaxPosition(ctx, excludeId) {
87
88
  }
88
89
  export async function handleCategoryWrite(input, ctx) {
89
90
  const { categoryId, before, after } = input;
91
+ // Drop the ISR entries for this category/brand/bundle page and the listings
92
+ // that link it. Folded into the existing trigger rather than given its own โ€”
93
+ // see the note in onProductWrite. No Firestore write here (Root Cause #92).
94
+ await notifyRevalidate({ collection: "categories", id: categoryId }, ctx);
90
95
  const isCreate = !before && !!after;
91
96
  const isDelete = !!before && !after;
92
97
  const isUpdate = !!before && !!after;
@@ -7,6 +7,7 @@ import { categoriesRepository, storeRepository, } from "../../../../repositories
7
7
  import { ProductStatusValues } from "../../../../features/products/schemas/firestore";
8
8
  import { PRODUCT_FIELDS } from "../../../../constants/field-names";
9
9
  import { isListingRowAvailable } from "../../../shared/listing-types/_registry";
10
+ import { notifyRevalidate, productCacheFieldsChanged } from "./revalidateNotify";
10
11
  const UNAVAILABLE_PRODUCT_STATUSES = new Set([
11
12
  ProductStatusValues.ARCHIVED,
12
13
  ]);
@@ -158,6 +159,36 @@ async function dispatchProductWriteEvent(p) {
158
159
  }
159
160
  export async function handleProductWrite(input, ctx) {
160
161
  const { productId, before, after } = input;
162
+ /*
163
+ * Drop the ISR entries for every page that renders this product.
164
+ *
165
+ * Deliberately folded into THIS handler rather than given a trigger of its
166
+ * own. `products/{productId}` already carries two `documentWritten` triggers;
167
+ * a third would add a whole function's worth of invocations for one `fetch`,
168
+ * and Root Cause #92's collateral is the standing warning about what happens
169
+ * when several functions share one hot path.
170
+ *
171
+ * Gated on a real content change so a stock decrement invalidates and an
172
+ * `updatedAt` bump does not. Awaited โ€” not fire-and-forget โ€” because a Cloud
173
+ * Function may be frozen the moment its promise settles, and a detached fetch
174
+ * would be cancelled mid-flight often enough to make invalidation unreliable
175
+ * without ever reporting a failure.
176
+ */
177
+ if (productCacheFieldsChanged(before, after)) {
178
+ const doc = after ?? before;
179
+ await notifyRevalidate({
180
+ collection: "products",
181
+ id: productId,
182
+ hints: {
183
+ listingType: doc?.listingType,
184
+ categorySlugs: Array.isArray(doc?.categorySlugs)
185
+ ? doc.categorySlugs
186
+ : undefined,
187
+ brandSlug: doc?.brandSlug,
188
+ storeId: doc?.storeId,
189
+ },
190
+ }, ctx);
191
+ }
161
192
  const beforeStatus = before?.status ?? null;
162
193
  const afterStatus = after?.status ?? null;
163
194
  // `category` is @deprecated in favor of `categorySlugs[]` โ€” the repository's
@@ -1,8 +1,16 @@
1
+ import { notifyRevalidate } from "./revalidateNotify";
1
2
  import { reviewRepository, storeRepository } from "../../../../repositories";
2
3
  import { ReviewStatusValues } from "../../../../features/reviews/schemas/firestore";
3
4
  import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore";
4
5
  export async function handleReviewWrite(input, ctx) {
5
6
  const { reviewId, before: beforeData, after: afterData } = input;
7
+ /*
8
+ * A review changes the product's rating badge AND the homepage's
9
+ * `platform_rating` / `total_reviews` stats, so the targets are the listing
10
+ * pages rather than a per-review page (there isn't one). Uses the review's own
11
+ * id purely for log correlation. No Firestore write here (Root Cause #92).
12
+ */
13
+ await notifyRevalidate({ collection: "reviews", id: reviewId }, ctx);
6
14
  const beforeStatus = beforeData?.status ?? null;
7
15
  const afterStatus = afterData?.status ?? null;
8
16
  const wasApproved = beforeStatus === ReviewStatusValues.APPROVED;
@@ -10,4 +10,4 @@ export interface HandleStoreWriteInput {
10
10
  before: StoreDoc | null;
11
11
  after: StoreDoc | null;
12
12
  }
13
- export declare function handleStoreWrite(_input: HandleStoreWriteInput, _ctx: JobContext): Promise<void>;
13
+ export declare function handleStoreWrite(input: HandleStoreWriteInput, ctx: JobContext): Promise<void>;
@@ -2,6 +2,12 @@
2
2
  * Core: stores/{id} onWrite โ€” currently a no-op. Previous implementation
3
3
  * synced an external search index that has since been removed.
4
4
  */
5
- export async function handleStoreWrite(_input, _ctx) {
6
- // Intentionally empty โ€” search provider removed; Firestore queries handle search now.
5
+ import { notifyRevalidate } from "./revalidateNotify";
6
+ export async function handleStoreWrite(input, ctx) {
7
+ // The search-index sync this used to perform is gone (Firestore queries handle
8
+ // search now), so the only remaining side-effect is cache invalidation: drop
9
+ // the ISR entries for this store's page and the stores listing. Reuses the
10
+ // existing trigger, so this costs no additional invocations, and performs no
11
+ // Firestore write (Root Cause #92).
12
+ await notifyRevalidate({ collection: "stores", id: input.storeId }, ctx);
7
13
  }
@@ -0,0 +1,28 @@
1
+ import type { JobContext } from "../runtime/types";
2
+ import type { JsonValue } from "@mohasinac/appkit";
3
+ /** Same shape the sibling job cores use (`ProductDoc`, `CategoryDoc`, โ€ฆ). */
4
+ type Doc = Record<string, JsonValue> | null | undefined;
5
+ /**
6
+ * Did anything a page renders actually change?
7
+ *
8
+ * ๐Ÿ›‘ Compared with `JSON.stringify` per FIELD, never over the whole document.
9
+ * Root Cause #92's bug was a whole-object `JSON.stringify` comparison that could
10
+ * never report "equal" because Firestore returns map keys alphabetically while
11
+ * the code built them in construction order. Per-field, the values are scalars
12
+ * or small arrays whose order IS meaningful, so stringify is sound here.
13
+ *
14
+ * A create or a delete always counts โ€” there is no previous/next state to diff.
15
+ */
16
+ export declare function productCacheFieldsChanged(before: Doc, after: Doc): boolean;
17
+ export interface RevalidateNotifyInput {
18
+ collection: string;
19
+ id: string;
20
+ hints?: {
21
+ listingType?: string;
22
+ categorySlugs?: string[];
23
+ brandSlug?: string;
24
+ storeId?: string;
25
+ };
26
+ }
27
+ export declare function notifyRevalidate(input: RevalidateNotifyInput, ctx: JobContext): Promise<void>;
28
+ export {};
@@ -0,0 +1,125 @@
1
+ import { normalizeError } from "../../../../errors/normalize";
2
+ /**
3
+ * Tell the Next.js app that a document changed, so it can drop the stale ISR
4
+ * entries for the pages that render it.
5
+ *
6
+ * ## Why an HTTP call and not `revalidatePath()`
7
+ *
8
+ * `revalidatePath` only exists inside the Next.js runtime. The writers that
9
+ * matter most do NOT run there: a checkout stock decrement, auction settlement,
10
+ * the nightly reconcilers and every `appkit-seed` write all execute in Cloud
11
+ * Functions or a CLI. A Firestore trigger is the one place that observes all of
12
+ * them, and the only way it can reach Next's cache is over the network.
13
+ *
14
+ * ## ๐Ÿ›‘ This does not write to Firestore โ€” by construction
15
+ *
16
+ * Root Cause #92: a trigger that writes back to the collection it watches is how
17
+ * this project produced 1.02M invocations/day and knocked out production. This
18
+ * performs exactly one `fetch` and touches no document, so it cannot re-trigger
19
+ * itself. Keep it that way โ€” if this ever needs to persist anything, it needs a
20
+ * different trigger path, not a write.
21
+ *
22
+ * ## Best-effort, and silent about it
23
+ *
24
+ * A failed invalidation must never fail the write side-effects it rides along
25
+ * with โ€” the product is already saved; the worst case is a page that stays stale
26
+ * until its TTL rolls. So this swallows every error after classifying it, and
27
+ * logs at `warn` once per failure rather than per document.
28
+ */
29
+ /** Fields whose change can alter what a public page renders. */
30
+ const CACHE_RELEVANT_PRODUCT_FIELDS = [
31
+ "status",
32
+ "stockQuantity",
33
+ "isSold",
34
+ "price",
35
+ "salePrice",
36
+ "title",
37
+ "images",
38
+ "categorySlugs",
39
+ "brandSlug",
40
+ "brand",
41
+ "storeId",
42
+ "listingType",
43
+ "auctionEndDate",
44
+ "currentBid",
45
+ "buyNowPrice",
46
+ "isFeatured",
47
+ "isPromoted",
48
+ "featured",
49
+ ];
50
+ /**
51
+ * Did anything a page renders actually change?
52
+ *
53
+ * ๐Ÿ›‘ Compared with `JSON.stringify` per FIELD, never over the whole document.
54
+ * Root Cause #92's bug was a whole-object `JSON.stringify` comparison that could
55
+ * never report "equal" because Firestore returns map keys alphabetically while
56
+ * the code built them in construction order. Per-field, the values are scalars
57
+ * or small arrays whose order IS meaningful, so stringify is sound here.
58
+ *
59
+ * A create or a delete always counts โ€” there is no previous/next state to diff.
60
+ */
61
+ export function productCacheFieldsChanged(before, after) {
62
+ if (!before || !after)
63
+ return true;
64
+ for (const field of CACHE_RELEVANT_PRODUCT_FIELDS) {
65
+ if (JSON.stringify(before[field] ?? null) !== JSON.stringify(after[field] ?? null)) {
66
+ return true;
67
+ }
68
+ }
69
+ return false;
70
+ }
71
+ /**
72
+ * The app origin to call.
73
+ *
74
+ * No existing job needed one, so this introduces `APP_ORIGIN`. Returning null
75
+ * (rather than guessing a host) makes an unconfigured deployment a no-op with a
76
+ * one-line warning instead of a stream of failed requests to the wrong site โ€”
77
+ * and the stale page it leaves behind is bounded by the TTL.
78
+ */
79
+ function appOrigin(ctx) {
80
+ const raw = ctx.env("APP_ORIGIN");
81
+ if (!raw)
82
+ return null;
83
+ return raw.replace(/\/+$/, "");
84
+ }
85
+ export async function notifyRevalidate(input, ctx) {
86
+ const origin = appOrigin(ctx);
87
+ const secret = ctx.env("CACHE_REVALIDATION_SECRET");
88
+ if (!origin || !secret) {
89
+ ctx.logger.warn("revalidate-notify: not configured โ€” skipping", {
90
+ hasOrigin: !!origin,
91
+ hasSecret: !!secret,
92
+ collection: input.collection,
93
+ });
94
+ return;
95
+ }
96
+ try {
97
+ const res = await fetch(`${origin}/api/cache/revalidate`, {
98
+ method: "POST",
99
+ headers: { "content-type": "application/json", "x-api-key": secret },
100
+ body: JSON.stringify({
101
+ collections: [input.collection],
102
+ id: input.id,
103
+ hints: input.hints ?? {},
104
+ }),
105
+ // The caller is a background trigger, not a user request โ€” but an
106
+ // unbounded fetch would pin the function open until the platform kills it.
107
+ signal: AbortSignal.timeout(5000),
108
+ });
109
+ if (!res.ok) {
110
+ ctx.logger.warn("revalidate-notify: endpoint returned non-ok", {
111
+ status: res.status,
112
+ collection: input.collection,
113
+ id: input.id,
114
+ });
115
+ }
116
+ }
117
+ catch (err) {
118
+ void normalizeError(err);
119
+ ctx.logger.warn("revalidate-notify: request failed", {
120
+ collection: input.collection,
121
+ id: input.id,
122
+ error: err instanceof Error ? err.message : String(err),
123
+ });
124
+ }
125
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Which PAGE paths a write to a collection makes stale.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Detail routes used to carry very short `revalidate` windows โ€” 30s on
7
+ * `auctions/[id]`, 60s on `products/[slug]` โ€” not because anyone wanted that
8
+ * freshness, but because **the window was the only freshness mechanism in the
9
+ * app**. Measured 2026-10-09: `revalidatePath` appeared three times in the whole
10
+ * repository, all `revalidatePath("/")`, and `revalidateTag` zero times.
11
+ *
12
+ * Short windows ร— many unique paths is how you manufacture ISR writes. 95
13
+ * products at 60s is up to 137K regenerations/day from one route, and the
14
+ * project was suspended for exceeding 200K ISR writes with 3.1M โ€” the signature
15
+ * being writes outnumbering reads 3:1, i.e. entries regenerating before anyone
16
+ * read them twice.
17
+ *
18
+ * Invalidation keyed on the WRITE is both cheaper and more correct than any
19
+ * clock: a product that changed is stale immediately, and one that did not never
20
+ * regenerates at all.
21
+ *
22
+ * ## Contract
23
+ *
24
+ * Returns **locale-less** paths. The caller is responsible for prefixing each
25
+ * with the locales it serves โ€” see the revalidate route, which does that from
26
+ * `routing.locales`, because prerendered entries live at `/en/...`.
27
+ *
28
+ * Paths are returned on a best-effort basis: a caller that cannot supply hints
29
+ * still gets the always-stale list, which is the part that matters for a
30
+ * listing. Over-revalidating is cheap (it marks an entry stale; it does not
31
+ * render). Under-revalidating is the failure that shows a buyer a sold-out
32
+ * listing as in stock, so when in doubt this returns MORE paths, not fewer.
33
+ */
34
+ /** What the caller knows about the written document, when it knows anything. */
35
+ export interface RevalidateHints {
36
+ /** `standard` | `auction` | `pre-order` | โ€ฆ โ€” picks the right detail route. */
37
+ listingType?: string;
38
+ /** Full ancestor chain; every category page listing this product goes stale. */
39
+ categorySlugs?: string[];
40
+ brandSlug?: string;
41
+ storeId?: string;
42
+ }
43
+ /**
44
+ * Page paths made stale by a write to `collection`.
45
+ *
46
+ * `id` and `hints` are optional; omitting them yields only the always-stale
47
+ * listing pages, which is the correct answer for a bulk or unknown write.
48
+ */
49
+ export declare function revalidateTargetsFor(collection: string, id?: string, hints?: RevalidateHints): string[];
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Which PAGE paths a write to a collection makes stale.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Detail routes used to carry very short `revalidate` windows โ€” 30s on
7
+ * `auctions/[id]`, 60s on `products/[slug]` โ€” not because anyone wanted that
8
+ * freshness, but because **the window was the only freshness mechanism in the
9
+ * app**. Measured 2026-10-09: `revalidatePath` appeared three times in the whole
10
+ * repository, all `revalidatePath("/")`, and `revalidateTag` zero times.
11
+ *
12
+ * Short windows ร— many unique paths is how you manufacture ISR writes. 95
13
+ * products at 60s is up to 137K regenerations/day from one route, and the
14
+ * project was suspended for exceeding 200K ISR writes with 3.1M โ€” the signature
15
+ * being writes outnumbering reads 3:1, i.e. entries regenerating before anyone
16
+ * read them twice.
17
+ *
18
+ * Invalidation keyed on the WRITE is both cheaper and more correct than any
19
+ * clock: a product that changed is stale immediately, and one that did not never
20
+ * regenerates at all.
21
+ *
22
+ * ## Contract
23
+ *
24
+ * Returns **locale-less** paths. The caller is responsible for prefixing each
25
+ * with the locales it serves โ€” see the revalidate route, which does that from
26
+ * `routing.locales`, because prerendered entries live at `/en/...`.
27
+ *
28
+ * Paths are returned on a best-effort basis: a caller that cannot supply hints
29
+ * still gets the always-stale list, which is the part that matters for a
30
+ * listing. Over-revalidating is cheap (it marks an entry stale; it does not
31
+ * render). Under-revalidating is the failure that shows a buyer a sold-out
32
+ * listing as in stock, so when in doubt this returns MORE paths, not fewer.
33
+ */
34
+ /** Detail route per listing type. Mirrors `ListingTypePlugin.detailRoute`. */
35
+ const PRODUCT_DETAIL_BY_TYPE = {
36
+ standard: (id) => `/products/${id}`,
37
+ art: (id) => `/products/${id}`,
38
+ stickers: (id) => `/products/${id}`,
39
+ auction: (id) => `/auctions/${id}`,
40
+ "pre-order": (id) => `/pre-orders/${id}`,
41
+ "prize-draw": (id) => `/prize-draws/${id}`,
42
+ classified: (id) => `/classified/${id}`,
43
+ "digital-code": (id) => `/digital-codes/${id}`,
44
+ live: (id) => `/live/${id}`,
45
+ };
46
+ /**
47
+ * Infer the listing type from the id prefix when no hint was supplied.
48
+ *
49
+ * The slug prefix system (CLAUDE.md ยง Slug Prefix System) makes this reliable
50
+ * for seeded and generated ids โ€” `auction-โ€ฆ`, `preorder-โ€ฆ`, `prizedraw-โ€ฆ` โ€” and
51
+ * it is only a fallback: the Firestore trigger reads the document and passes
52
+ * `listingType` explicitly.
53
+ */
54
+ function listingTypeFromId(id) {
55
+ if (id.startsWith("auction-"))
56
+ return "auction";
57
+ if (id.startsWith("preorder-"))
58
+ return "pre-order";
59
+ if (id.startsWith("prizedraw-"))
60
+ return "prize-draw";
61
+ if (id.startsWith("classified-"))
62
+ return "classified";
63
+ if (id.startsWith("digitalcode-"))
64
+ return "digital-code";
65
+ if (id.startsWith("live-"))
66
+ return "live";
67
+ if (id.startsWith("art-"))
68
+ return "art";
69
+ if (id.startsWith("sticker-"))
70
+ return "stickers";
71
+ if (id.startsWith("product-"))
72
+ return "standard";
73
+ return undefined;
74
+ }
75
+ function productTargets(id, hints) {
76
+ const out = ["/", "/products"];
77
+ if (!id)
78
+ return out;
79
+ const type = hints?.listingType ?? listingTypeFromId(id);
80
+ if (type && PRODUCT_DETAIL_BY_TYPE[type]) {
81
+ out.push(PRODUCT_DETAIL_BY_TYPE[type](id));
82
+ // The type's own browse page, when it has one distinct from /products.
83
+ const browse = PRODUCT_DETAIL_BY_TYPE[type](id).replace(/\/[^/]+$/, "");
84
+ if (browse && browse !== "/products")
85
+ out.push(browse);
86
+ }
87
+ else {
88
+ /*
89
+ * Unknown type: revalidate EVERY detail route for this id rather than
90
+ * guessing one. At most nine entries marked stale, none of which renders
91
+ * until requested โ€” strictly cheaper than leaving the real one live.
92
+ */
93
+ for (const build of Object.values(PRODUCT_DETAIL_BY_TYPE))
94
+ out.push(build(id));
95
+ }
96
+ // A product's price/stock/status is rendered on every taxonomy page that
97
+ // lists it. `categorySlugs` is the full ancestor chain, so this covers the
98
+ // leaf and every parent up to the root.
99
+ for (const slug of hints?.categorySlugs ?? [])
100
+ out.push(`/categories/${slug}`);
101
+ if (hints?.brandSlug)
102
+ out.push(`/brands/${hints.brandSlug}`);
103
+ if (hints?.storeId)
104
+ out.push(`/stores/${hints.storeId}`);
105
+ return out;
106
+ }
107
+ /**
108
+ * Page paths made stale by a write to `collection`.
109
+ *
110
+ * `id` and `hints` are optional; omitting them yields only the always-stale
111
+ * listing pages, which is the correct answer for a bulk or unknown write.
112
+ */
113
+ export function revalidateTargetsFor(collection, id, hints) {
114
+ let paths;
115
+ switch (collection) {
116
+ case "products":
117
+ paths = productTargets(id, hints);
118
+ break;
119
+ case "categories":
120
+ // Brands and pricing bundles are `categories` rows discriminated by
121
+ // `categoryType`, so all three detail shapes are marked.
122
+ paths = ["/", "/categories"];
123
+ if (id)
124
+ paths.push(`/categories/${id}`, `/brands/${id}`, `/bundles/${id}`);
125
+ break;
126
+ case "blogPosts":
127
+ paths = ["/", "/blog"];
128
+ if (id)
129
+ paths.push(`/blog/${id}`);
130
+ break;
131
+ case "events":
132
+ paths = ["/", "/events"];
133
+ if (id)
134
+ paths.push(`/events/${id}`);
135
+ break;
136
+ case "reviews":
137
+ // A new review changes the product's rating and the homepage stat.
138
+ paths = ["/", "/reviews"];
139
+ break;
140
+ case "stores":
141
+ paths = ["/", "/stores"];
142
+ if (id)
143
+ paths.push(`/stores/${id}`);
144
+ break;
145
+ case "carouselSlides":
146
+ case "homepageSections":
147
+ case "siteSettings":
148
+ paths = ["/"];
149
+ break;
150
+ case "faqs":
151
+ paths = ["/", "/faqs"];
152
+ break;
153
+ default:
154
+ paths = [];
155
+ }
156
+ return Array.from(new Set(paths));
157
+ }
@@ -6,6 +6,7 @@ import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices";
6
6
  import { formatCurrency } from "../../../utils/number.formatter";
7
7
  import { ACTION_ID, ACTION_META } from "../../products/constants/action-defs";
8
8
  import { useCountdown } from "../../../react";
9
+ import { useIsAuctionEnded } from "../hooks/useIsAuctionEnded";
9
10
  import { Modal } from "../../../ui";
10
11
  import { isBuyNowAvailable } from "../../../_internal/shared/features/auctions/config";
11
12
  import { PlaceBidFormClient, } from "./PlaceBidFormClient";
@@ -38,7 +39,15 @@ function formatCountdownLabel(remaining) {
38
39
  * button can report an error or route to `/checkout?lane=auction`.
39
40
  */
40
41
  export function AuctionBottomActions(props) {
41
- const { currentBid, currency, bidCount, isEnded, auctionEndDate, buyNowPrice } = props;
42
+ const { currentBid, currency, bidCount, isEnded: ssrIsEnded, auctionEndDate, buyNowPrice } = props;
43
+ /*
44
+ * Derived client-side โ€” see `useIsAuctionEnded`. This bar is a FIXED, always-
45
+ * visible row on mobile, so a stale "Place bid" here is the most prominent
46
+ * wrong affordance on the page. `useCountdown` below already knew the truth
47
+ * (it returns null past the target) but only fed a label; the bar's actions
48
+ * were gated on the frozen server boolean.
49
+ */
50
+ const isEnded = useIsAuctionEnded(auctionEndDate, ssrIsEnded);
42
51
  const [open, setOpen] = useState(false);
43
52
  const remaining = useCountdown(auctionEndDate ?? undefined);
44
53
  const { canSeePrices } = useCanSeePrices();