@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
|
@@ -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: !
|
|
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 ?
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
6
|
-
|
|
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();
|