@mohasinac/appkit 4.66.0 → 4.67.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/.build-stamp.json +3 -3
- package/dist/_internal/client/features/maintenance/index.d.ts +1 -0
- package/dist/_internal/client/features/maintenance/index.js +1 -0
- package/dist/_internal/client/features/maintenance/views/CacheInvalidationPanel.d.ts +33 -0
- package/dist/_internal/client/features/maintenance/views/CacheInvalidationPanel.js +96 -0
- package/dist/_internal/client/features/maintenance/views/MaintenanceDashboardView.js +2 -1
- package/dist/_internal/server/features/products/surface-counts.d.ts +26 -0
- package/dist/_internal/server/features/products/surface-counts.js +60 -0
- package/dist/_internal/server/features/seo/sitemap.js +37 -3
- package/dist/_internal/server/features/site-settings/adapters.d.ts +16 -0
- package/dist/_internal/server/features/site-settings/adapters.js +11 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +33 -0
- package/dist/_internal/server/jobs/core/surfaceCountsRollup.d.ts +55 -0
- package/dist/_internal/server/jobs/core/surfaceCountsRollup.js +128 -0
- package/dist/_internal/server/jobs/handlers/countersReconcile.js +54 -1
- package/dist/_internal/shared/actions/action-registry.js +28 -0
- package/dist/_internal/shared/cache/revalidate-targets.js +66 -47
- package/dist/_internal/shared/features/site-settings/types.d.ts +17 -0
- package/dist/_internal/shared/listing-types/feature-flags.d.ts +45 -0
- package/dist/_internal/shared/listing-types/feature-flags.js +51 -0
- package/dist/client.d.ts +4 -2
- package/dist/client.js +10 -1
- package/dist/constants/api-endpoints.d.ts +3 -0
- package/dist/constants/api-endpoints.js +11 -0
- package/dist/features/products/schemas/surface-counts.d.ts +83 -0
- package/dist/features/products/schemas/surface-counts.js +58 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -1
- package/dist/react/hooks/useListingSurfaceVisibility.d.ts +44 -0
- package/dist/react/hooks/useListingSurfaceVisibility.js +20 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +12 -0
- package/package.json +33 -1
|
@@ -1,2 +1,55 @@
|
|
|
1
|
+
import { normalizeError } from "../../../../errors/normalize.js";
|
|
1
2
|
import { runCountersReconcile } from "../core/countersReconcile.js";
|
|
2
|
-
|
|
3
|
+
import { runSurfaceCountsRollup } from "../core/surfaceCountsRollup.js";
|
|
4
|
+
/*
|
|
5
|
+
* 🛑 The surface-count rollup rides THIS job rather than taking a Scheduler
|
|
6
|
+
* slot of its own.
|
|
7
|
+
*
|
|
8
|
+
* Two reasons, and the second is the one that matters:
|
|
9
|
+
*
|
|
10
|
+
* 1. Cost — every registered Scheduler job is billed (~$0.10/mo beyond the
|
|
11
|
+
* free 3), and the Firebase budget table in CLAUDE.md says to fold new
|
|
12
|
+
* periodic work into an existing cadence when it is a natural fit.
|
|
13
|
+
* 2. Semantics — `countersReconcile` IS the nightly healer for incrementally
|
|
14
|
+
* maintained counters. `surfaceCounts` is incrementally maintained by
|
|
15
|
+
* `onProductWrite` under exactly the same "non-fatal, the nightly job will
|
|
16
|
+
* heal it" contract. Putting the healer somewhere else would mean two jobs
|
|
17
|
+
* owning one guarantee.
|
|
18
|
+
*
|
|
19
|
+
* Sequential, not `Promise.all`: both walk products, and running them together
|
|
20
|
+
* doubles the peak read rate for no wall-clock benefit on a 540s budget.
|
|
21
|
+
*
|
|
22
|
+
* 🛑 Its failure must not fail the reconcile. The counters this job exists for
|
|
23
|
+
* are the ones two Sieve SORTS order by; a surface count is a display decision
|
|
24
|
+
* that degrades to "unknown", i.e. to showing the surface. Never let the
|
|
25
|
+
* cosmetic half take down the load-bearing half.
|
|
26
|
+
*/
|
|
27
|
+
export const countersReconcileHandler = async (ctx) => {
|
|
28
|
+
const result = await runCountersReconcile(ctx);
|
|
29
|
+
try {
|
|
30
|
+
const surfaces = await runSurfaceCountsRollup(ctx);
|
|
31
|
+
/*
|
|
32
|
+
* Serialised to `"type=n"` pairs rather than logged as the maps
|
|
33
|
+
* themselves: the logger's value type is `JsonValue`, and a
|
|
34
|
+
* `Partial<Record<K, number>>` is not assignable to it (an optional
|
|
35
|
+
* property can be `undefined`, which is not JSON). Flattening is also the
|
|
36
|
+
* more readable log line.
|
|
37
|
+
*/
|
|
38
|
+
ctx.logger.info("Surface counts rolled up", {
|
|
39
|
+
listingTypes: Object.entries(surfaces.listingTypes)
|
|
40
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
41
|
+
.join(" "),
|
|
42
|
+
surfaces: Object.entries(surfaces.surfaces)
|
|
43
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
44
|
+
.join(" "),
|
|
45
|
+
// Named rather than counted — a surface left unmeasured stays visible,
|
|
46
|
+
// and an operator needs to know WHICH, not how many.
|
|
47
|
+
failed: surfaces.failed.join(",") || "none",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
void normalizeError(err);
|
|
52
|
+
ctx.logger.error("Surface-count rollup failed (non-fatal)", err);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
};
|
|
@@ -1895,6 +1895,34 @@ export const ACTIONS = {
|
|
|
1895
1895
|
kind: "primary",
|
|
1896
1896
|
permissions: ["admin"],
|
|
1897
1897
|
},
|
|
1898
|
+
"invalidate-cache": {
|
|
1899
|
+
// `retry` — the icon registry has no `refresh`/`reload`/`sync` key, and
|
|
1900
|
+
// inventing one means a new glyph in a catalogued set. `retry` is the
|
|
1901
|
+
// registered "do this again" mark and reads correctly for a re-render.
|
|
1902
|
+
iconKey: "retry",
|
|
1903
|
+
id: "admin.invalidate-cache",
|
|
1904
|
+
label: "Invalidate all cached pages",
|
|
1905
|
+
ariaLabel: "Mark every cached public page stale",
|
|
1906
|
+
description: "Mark every cached public page stale so the next visitor gets a freshly rendered one. Use after a bulk data change the Firestore triggers could not see — a seed load, a direct Firestore edit, or a catalogue purge.",
|
|
1907
|
+
kind: "secondary",
|
|
1908
|
+
permissions: ["admin"],
|
|
1909
|
+
/*
|
|
1910
|
+
* 🛑 A confirmation on a NON-destructive action, because the cost is
|
|
1911
|
+
* real and invisible at the click.
|
|
1912
|
+
*
|
|
1913
|
+
* This marks entries stale; it renders nothing and destroys nothing. But
|
|
1914
|
+
* every marked entry regenerates on its next request, and this project
|
|
1915
|
+
* was SUSPENDED (HTTP 402) for exceeding 200K ISR writes with 3.1M —
|
|
1916
|
+
* writes outnumbering reads 3:1. Rule #6's whole cache posture exists to
|
|
1917
|
+
* keep that from recurring, so a button that re-renders the public site
|
|
1918
|
+
* on demand must not be reachable by a stray click.
|
|
1919
|
+
*/
|
|
1920
|
+
confirmation: {
|
|
1921
|
+
title: "Invalidate every cached page?",
|
|
1922
|
+
body: "Every public page is marked stale and re-renders on its next visit. Nothing is deleted and no data changes, but the re-render costs function invocations against the plan quota — so use it after a bulk change, not as a routine refresh.",
|
|
1923
|
+
confirmLabel: "Invalidate all",
|
|
1924
|
+
},
|
|
1925
|
+
},
|
|
1898
1926
|
"update-pricing": {
|
|
1899
1927
|
iconKey: "save",
|
|
1900
1928
|
id: "admin.update-pricing",
|
|
@@ -33,67 +33,86 @@
|
|
|
33
33
|
*/
|
|
34
34
|
/** What the caller knows about the written document, when it knows anything. */
|
|
35
35
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
36
|
+
import { LISTING_TYPE_REGISTRY } from "../listing-types/_registry.js";
|
|
37
|
+
/*
|
|
38
|
+
* 🛑 DERIVED from `LISTING_TYPE_REGISTRY`, never hand-written.
|
|
39
|
+
*
|
|
40
|
+
* This file carried its own nine-entry `PRODUCT_DETAIL_BY_TYPE` literal and its
|
|
41
|
+
* own `listingTypeFromId` prefix ladder, and both had already drifted by the
|
|
42
|
+
* time the union reached ten — exactly the tenth-enumeration failure Root Cause
|
|
43
|
+
* #61 is about. Measured 2026-10-26, while invalidating after a catalogue purge
|
|
44
|
+
* that deleted 7 `spares` and 2 `stickers`:
|
|
45
|
+
*
|
|
46
|
+
* - **`spares` was absent from both.** A `spare-…` id fell through to the
|
|
47
|
+
* "unknown type" branch, which marks every OTHER type's detail route and
|
|
48
|
+
* therefore never marks `/products/spare-…` — the one page that existed.
|
|
49
|
+
* - **`/art`, `/stickers` and `/spares` were never marked at all.** The browse
|
|
50
|
+
* path was derived by stripping the last segment off the detail route, and
|
|
51
|
+
* all three types detail at `/products/{slug}`, so the strip yielded
|
|
52
|
+
* `/products` and the `!== ROUTES.PUBLIC.PRODUCTS` test discarded it. Their
|
|
53
|
+
* dedicated browse pages are in `MAIN_NAV_ITEMS` and went stale on a clock.
|
|
54
|
+
*
|
|
55
|
+
* `browseRoute` and `slugPrefix` are required plugin fields, so a type cannot
|
|
56
|
+
* join the union without answering both — which is the property the literal
|
|
57
|
+
* could never have.
|
|
58
|
+
*/
|
|
59
|
+
const PLUGINS = Object.values(LISTING_TYPE_REGISTRY);
|
|
48
60
|
/**
|
|
49
61
|
* Infer the listing type from the id prefix when no hint was supplied.
|
|
50
62
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* `
|
|
63
|
+
* Calls the shipped `slugPrefix` on each plugin rather than re-deriving the
|
|
64
|
+
* prefix map — the same decision `audit-seed-listing-type`'s R3 makes, and the
|
|
65
|
+
* reason that audit caught five `stickers-` ids against a registered
|
|
66
|
+
* `sticker-` prefix. Returns `undefined` rather than `detectListingTypeFromSlug`'s
|
|
67
|
+
* `"standard"` default: here, "no prefix matched" must widen to every detail
|
|
68
|
+
* route, not silently narrow to one.
|
|
55
69
|
*/
|
|
56
70
|
function listingTypeFromId(id) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (id.startsWith("prizedraw-"))
|
|
62
|
-
return "prize-draw";
|
|
63
|
-
if (id.startsWith("classified-"))
|
|
64
|
-
return "classified";
|
|
65
|
-
if (id.startsWith("digitalcode-"))
|
|
66
|
-
return "digital-code";
|
|
67
|
-
if (id.startsWith("live-"))
|
|
68
|
-
return "live";
|
|
69
|
-
if (id.startsWith("art-"))
|
|
70
|
-
return "art";
|
|
71
|
-
if (id.startsWith("sticker-"))
|
|
72
|
-
return "stickers";
|
|
73
|
-
if (id.startsWith("product-"))
|
|
74
|
-
return "standard";
|
|
71
|
+
for (const p of PLUGINS) {
|
|
72
|
+
if (p.slugPrefix && id.startsWith(p.slugPrefix))
|
|
73
|
+
return p.listingType;
|
|
74
|
+
}
|
|
75
75
|
return undefined;
|
|
76
76
|
}
|
|
77
77
|
function productTargets(id, hints) {
|
|
78
78
|
const out = [ROUTES.HOME, ROUTES.PUBLIC.PRODUCTS];
|
|
79
|
-
if (!id)
|
|
80
|
-
|
|
79
|
+
if (!id) {
|
|
80
|
+
/*
|
|
81
|
+
* Bulk / unknown write — a seed load, a purge, or the admin "invalidate
|
|
82
|
+
* everything" button. `/products` alone is not "every listing page": five
|
|
83
|
+
* types are deliberately OUT of the general catalogue
|
|
84
|
+
* (`inGeneralCatalogue: false`) and are reachable only at their own browse
|
|
85
|
+
* route, so a bulk invalidation that marks `/products` leaves `/auctions`,
|
|
86
|
+
* `/pre-orders`, `/prize-draws`, `/art`, `/stickers` and `/spares` serving
|
|
87
|
+
* rows that no longer exist.
|
|
88
|
+
*/
|
|
89
|
+
for (const p of PLUGINS)
|
|
90
|
+
if (p.browseRoute)
|
|
91
|
+
out.push(String(p.browseRoute));
|
|
92
|
+
return Array.from(new Set(out));
|
|
93
|
+
}
|
|
81
94
|
const type = hints?.listingType ?? listingTypeFromId(id);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
const plugin = type ? LISTING_TYPE_REGISTRY[type] : undefined;
|
|
96
|
+
if (plugin) {
|
|
97
|
+
out.push(String(plugin.detailRoute(id)));
|
|
98
|
+
// The type's own browse page. Read off the plugin, NOT derived by stripping
|
|
99
|
+
// the detail route's last segment: art/stickers/spares all detail at
|
|
100
|
+
// `/products/{slug}`, so stripping yields `/products` and loses `/art`,
|
|
101
|
+
// `/stickers` and `/spares` entirely.
|
|
102
|
+
if (plugin.browseRoute)
|
|
103
|
+
out.push(String(plugin.browseRoute));
|
|
88
104
|
}
|
|
89
105
|
else {
|
|
90
106
|
/*
|
|
91
|
-
* Unknown type: revalidate EVERY detail route for this id rather
|
|
92
|
-
* guessing one.
|
|
93
|
-
* until requested — strictly cheaper than leaving the real one live.
|
|
107
|
+
* Unknown type: revalidate EVERY detail and browse route for this id rather
|
|
108
|
+
* than guessing one. A couple of dozen entries marked stale, none of which
|
|
109
|
+
* renders until requested — strictly cheaper than leaving the real one live.
|
|
94
110
|
*/
|
|
95
|
-
for (const
|
|
96
|
-
out.push(
|
|
111
|
+
for (const p of PLUGINS) {
|
|
112
|
+
out.push(String(p.detailRoute(id)));
|
|
113
|
+
if (p.browseRoute)
|
|
114
|
+
out.push(String(p.browseRoute));
|
|
115
|
+
}
|
|
97
116
|
}
|
|
98
117
|
// A product's price/stock/status is rendered on every taxonomy page that
|
|
99
118
|
// lists it. `categorySlugs` is the full ancestor chain, so this covers the
|
|
@@ -117,4 +117,21 @@ export interface PublicSiteSettings {
|
|
|
117
117
|
disabledRoutes: string[];
|
|
118
118
|
/** Derived, not a stored field — the resolved marker → wordmark → text chain. */
|
|
119
119
|
effectiveWatermark?: MediaVideoWatermark;
|
|
120
|
+
/**
|
|
121
|
+
* Which public browse surfaces currently have something in them. Derived
|
|
122
|
+
* from the `surfaceCounts/global` document, not a `siteSettings` field —
|
|
123
|
+
* carried here because this payload is already the channel the navbar,
|
|
124
|
+
* footer and sidebar read their visibility rules from, so adding a second
|
|
125
|
+
* client fetch for one more rule would be a request per page load.
|
|
126
|
+
*
|
|
127
|
+
* 🛑 OMITTED ENTIRELY when the counts document has not been written yet,
|
|
128
|
+
* and every reader must treat that as "show everything". A present-but-empty
|
|
129
|
+
* object would be indistinguishable from "measured, and all zero" — which is
|
|
130
|
+
* the shape that hides the whole catalogue. `isListingSurfaceVisible`
|
|
131
|
+
* encodes the rule; do not re-derive it at a call site.
|
|
132
|
+
*/
|
|
133
|
+
surfaceCounts?: {
|
|
134
|
+
listingTypes?: Record<string, number>;
|
|
135
|
+
surfaces?: Record<string, number>;
|
|
136
|
+
};
|
|
120
137
|
}
|
|
@@ -97,5 +97,50 @@ export declare const ALL_LISTING_TYPES: ListingType[];
|
|
|
97
97
|
export declare function isListingType(value: unknown): value is ListingType;
|
|
98
98
|
/** Pull the full list of enabled listing types in canonical iteration order. */
|
|
99
99
|
export declare function enabledListingTypes(settings: FeatureFlagSnapshot | null | undefined): ListingType[];
|
|
100
|
+
/**
|
|
101
|
+
* The measured counts, as the public site-settings payload carries them.
|
|
102
|
+
*
|
|
103
|
+
* Structurally typed rather than importing `SurfaceCountsDocument` so this
|
|
104
|
+
* module stays a pure constants file with no feature import — the same reason
|
|
105
|
+
* it imports only `ROUTES`.
|
|
106
|
+
*/
|
|
107
|
+
export interface SurfaceCountSnapshot {
|
|
108
|
+
listingTypes?: Partial<Record<ListingType, number>>;
|
|
109
|
+
surfaces?: Partial<Record<string, number>>;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Should this listing type's public surface be shown at all?
|
|
113
|
+
*
|
|
114
|
+
* 🛑 THE PRECEDENCE, and why it is not just `count > 0`:
|
|
115
|
+
*
|
|
116
|
+
* | admin flag | measured count | result | because |
|
|
117
|
+
* |---|---|---|---|
|
|
118
|
+
* | `false` | anything | **hidden** | "we do not sell live animals" is a standing catalogue decision, not a statement about today's stock. An admin who closed a type must not see it reopen because someone listed one. |
|
|
119
|
+
* | `true` | `0` | **hidden** | auto-hide: the thing this function exists for. |
|
|
120
|
+
* | `true` | `> 0` | shown | |
|
|
121
|
+
* | `true` | **absent** | **shown** | 🛑 unknown is NOT zero. A fresh install, a failed job, or a type added since the last recompute must render exactly as it does today, never as hidden. Root Cause #72. |
|
|
122
|
+
*
|
|
123
|
+
* The flag therefore stays the authority and the count only ever SUBTRACTS
|
|
124
|
+
* from what the flag already permits. Two mechanisms, one direction — which is
|
|
125
|
+
* what stops them becoming two switches that disagree.
|
|
126
|
+
*/
|
|
127
|
+
export declare function isListingSurfaceVisible(type: ListingType, settings: FeatureFlagSnapshot | null | undefined, counts: SurfaceCountSnapshot | null | undefined): boolean;
|
|
128
|
+
/**
|
|
129
|
+
* The same question for a surface with no listing-type flag behind it
|
|
130
|
+
* (`/reviews` today).
|
|
131
|
+
*
|
|
132
|
+
* No admin-flag half: these have no switch to consult, so emptiness is the
|
|
133
|
+
* only input. Absent still means shown.
|
|
134
|
+
*/
|
|
135
|
+
export declare function isCountedSurfaceVisible(surface: string, counts: SurfaceCountSnapshot | null | undefined): boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Visible listing types — the flag list, minus the ones measured empty.
|
|
138
|
+
*
|
|
139
|
+
* This is what nav, footer and the sitemap filter on. `enabledListingTypes`
|
|
140
|
+
* above is deliberately left alone: it answers "what does this marketplace
|
|
141
|
+
* sell", which `/api/products`' post-filter and the seller forms still need
|
|
142
|
+
* even for a type with zero rows today.
|
|
143
|
+
*/
|
|
144
|
+
export declare function visibleListingTypes(settings: FeatureFlagSnapshot | null | undefined, counts: SurfaceCountSnapshot | null | undefined): ListingType[];
|
|
100
145
|
export declare function enabledCategoryTypes(settings: FeatureFlagSnapshot | null | undefined): CategoryType[];
|
|
101
146
|
export {};
|
|
@@ -140,6 +140,57 @@ export function isListingType(value) {
|
|
|
140
140
|
export function enabledListingTypes(settings) {
|
|
141
141
|
return ALL_LISTING_TYPES.filter((t) => isListingTypeEnabled(t, settings));
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Should this listing type's public surface be shown at all?
|
|
145
|
+
*
|
|
146
|
+
* 🛑 THE PRECEDENCE, and why it is not just `count > 0`:
|
|
147
|
+
*
|
|
148
|
+
* | admin flag | measured count | result | because |
|
|
149
|
+
* |---|---|---|---|
|
|
150
|
+
* | `false` | anything | **hidden** | "we do not sell live animals" is a standing catalogue decision, not a statement about today's stock. An admin who closed a type must not see it reopen because someone listed one. |
|
|
151
|
+
* | `true` | `0` | **hidden** | auto-hide: the thing this function exists for. |
|
|
152
|
+
* | `true` | `> 0` | shown | |
|
|
153
|
+
* | `true` | **absent** | **shown** | 🛑 unknown is NOT zero. A fresh install, a failed job, or a type added since the last recompute must render exactly as it does today, never as hidden. Root Cause #72. |
|
|
154
|
+
*
|
|
155
|
+
* The flag therefore stays the authority and the count only ever SUBTRACTS
|
|
156
|
+
* from what the flag already permits. Two mechanisms, one direction — which is
|
|
157
|
+
* what stops them becoming two switches that disagree.
|
|
158
|
+
*/
|
|
159
|
+
export function isListingSurfaceVisible(type, settings, counts) {
|
|
160
|
+
if (!isListingTypeEnabled(type, settings))
|
|
161
|
+
return false;
|
|
162
|
+
const n = counts?.listingTypes?.[type];
|
|
163
|
+
// `undefined` -> true. Written as an explicit test rather than `(n ?? 1) > 0`
|
|
164
|
+
// so the unknown case is readable, and so nobody "simplifies" it into a
|
|
165
|
+
// falsy check that folds 0 and undefined together — which is the bug.
|
|
166
|
+
if (n === undefined)
|
|
167
|
+
return true;
|
|
168
|
+
return n > 0;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The same question for a surface with no listing-type flag behind it
|
|
172
|
+
* (`/reviews` today).
|
|
173
|
+
*
|
|
174
|
+
* No admin-flag half: these have no switch to consult, so emptiness is the
|
|
175
|
+
* only input. Absent still means shown.
|
|
176
|
+
*/
|
|
177
|
+
export function isCountedSurfaceVisible(surface, counts) {
|
|
178
|
+
const n = counts?.surfaces?.[surface];
|
|
179
|
+
if (n === undefined)
|
|
180
|
+
return true;
|
|
181
|
+
return n > 0;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Visible listing types — the flag list, minus the ones measured empty.
|
|
185
|
+
*
|
|
186
|
+
* This is what nav, footer and the sitemap filter on. `enabledListingTypes`
|
|
187
|
+
* above is deliberately left alone: it answers "what does this marketplace
|
|
188
|
+
* sell", which `/api/products`' post-filter and the seller forms still need
|
|
189
|
+
* even for a type with zero rows today.
|
|
190
|
+
*/
|
|
191
|
+
export function visibleListingTypes(settings, counts) {
|
|
192
|
+
return ALL_LISTING_TYPES.filter((t) => isListingSurfaceVisible(t, settings, counts));
|
|
193
|
+
}
|
|
143
194
|
export function enabledCategoryTypes(settings) {
|
|
144
195
|
const all = ["category", "sublisting", "brand", "bundle"];
|
|
145
196
|
return all.filter((t) => isCategoryTypeEnabled(t, settings));
|
package/dist/client.d.ts
CHANGED
|
@@ -422,6 +422,8 @@ export { useListingTypeFlags } from "./react/hooks/useListingTypeFlags";
|
|
|
422
422
|
export type { ListingTypeFlags, ListingTypeFlagsShape, } from "./react/hooks/useListingTypeFlags";
|
|
423
423
|
export { useBrowsePageFlags } from "./react/hooks/useBrowsePageFlags";
|
|
424
424
|
export type { BrowsePageFlags, BrowsePageFlagsShape, } from "./react/hooks/useBrowsePageFlags";
|
|
425
|
+
export { useListingSurfaceVisibility } from "./react/hooks/useListingSurfaceVisibility";
|
|
426
|
+
export type { ListingSurfaceVisibility } from "./react/hooks/useListingSurfaceVisibility";
|
|
425
427
|
export { useActionDispatch } from "./react/hooks/use-action-dispatch";
|
|
426
428
|
export type { DispatchAction, UseActionDispatchOptions } from "./react/hooks/use-action-dispatch";
|
|
427
429
|
export { usePanelStore } from "./stores/panel-store";
|
|
@@ -535,8 +537,8 @@ export type { TrackArgs as TrackHistoryArgs } from "./features/history/hooks/use
|
|
|
535
537
|
export { WISHLIST_MAX, HISTORY_MAX, CART_MAX_ITEMS, } from "./constants/limits";
|
|
536
538
|
export { normalizeListingType, isAuctionListing, isPreOrderListing, isStandardListing, isPrizeDrawListing, isClassifiedListing, isDigitalCodeListing, isLiveListing, isArtListing, isStickersListing, } from "./features/products/utils/listing-type";
|
|
537
539
|
export { isFinalSale, acceptsChangeOfMindReturns, } from "./features/products/constants/final-sale";
|
|
538
|
-
export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, ALL_LISTING_TYPES, isListingType, isBrowsePageEnabled, enabledBrowsePages, ALL_BROWSE_PAGES, isBrowsePageKey, } from "./_internal/shared/listing-types/feature-flags";
|
|
539
|
-
export type { BrowsePageKey } from "./_internal/shared/listing-types/feature-flags";
|
|
540
|
+
export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, ALL_LISTING_TYPES, isListingType, isBrowsePageEnabled, enabledBrowsePages, ALL_BROWSE_PAGES, isBrowsePageKey, isListingSurfaceVisible, isCountedSurfaceVisible, visibleListingTypes, } from "./_internal/shared/listing-types/feature-flags";
|
|
541
|
+
export type { BrowsePageKey, SurfaceCountSnapshot, } from "./_internal/shared/listing-types/feature-flags";
|
|
540
542
|
export { ADVANCED_LISTING_TYPE_CAPABILITY, ADVANCED_LISTING_TYPES, BASIC_LISTING_TYPES, LISTING_TYPE_TIER, hasAdvancedListingTypes, isAdvancedListingType, isListingTypeAllowedFor, listingTypesAllowedFor, } from "./_internal/shared/listing-types/tiers";
|
|
541
543
|
export type { ListingTypeAccessContext, ListingTypeTier, } from "./_internal/shared/listing-types/tiers";
|
|
542
544
|
export { actionTracker, setActionTrackerSink, resetActionTrackerSink, type ActionEvent, type ActionTrackerSink, } from "./_internal/shared/listing-types/action-tracker";
|
package/dist/client.js
CHANGED
|
@@ -448,6 +448,11 @@ export { useListingTypeFlags } from "./react/hooks/useListingTypeFlags.js";
|
|
|
448
448
|
// [CLIENT-ONLY] useBrowsePageFlags: read listings.browsePages from
|
|
449
449
|
// siteSettings; drop nav/sidebar/footer links into a closed browse section.
|
|
450
450
|
export { useBrowsePageFlags } from "./react/hooks/useBrowsePageFlags.js";
|
|
451
|
+
// [CLIENT-ONLY] useListingSurfaceVisibility: the admin flag MINUS the surfaces
|
|
452
|
+
// measured as empty. For BROWSING links only — authoring surfaces (the seller
|
|
453
|
+
// type dropdown) must keep using useListingTypeFlags, or the first listing of
|
|
454
|
+
// an emptied type becomes impossible to create.
|
|
455
|
+
export { useListingSurfaceVisibility } from "./react/hooks/useListingSurfaceVisibility.js";
|
|
451
456
|
// Action dispatch hook + panel store
|
|
452
457
|
export { useActionDispatch } from "./react/hooks/use-action-dispatch.js";
|
|
453
458
|
export { usePanelStore } from "./stores/panel-store.js";
|
|
@@ -542,7 +547,11 @@ export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabl
|
|
|
542
547
|
// Browse-section gates. Client-safe and genuinely needed here: the nav,
|
|
543
548
|
// sidebar, mobile sheet and footer all filter on them, and a link into a
|
|
544
549
|
// closed section is a 404 on every page of the site.
|
|
545
|
-
isBrowsePageEnabled, enabledBrowsePages, ALL_BROWSE_PAGES, isBrowsePageKey,
|
|
550
|
+
isBrowsePageEnabled, enabledBrowsePages, ALL_BROWSE_PAGES, isBrowsePageKey,
|
|
551
|
+
// Auto-hide: a surface measured as having nothing in it. Client-safe and
|
|
552
|
+
// needed here for the same reason as the browse-section gates above — the
|
|
553
|
+
// nav, sidebar, mobile sheet and footer all filter on them.
|
|
554
|
+
isListingSurfaceVisible, isCountedSurfaceVisible, visibleListingTypes, } from "./_internal/shared/listing-types/feature-flags.js";
|
|
546
555
|
// Basic/advanced listing-type split. Client-safe: the nav filter and any
|
|
547
556
|
// seller-facing picker need the same rule the server enforces, and deriving it
|
|
548
557
|
// twice is how the ten hand-written copies of the union happened.
|
|
@@ -264,6 +264,7 @@ export declare const ADMIN_ENDPOINTS: {
|
|
|
264
264
|
readonly CHECKOUT_BYPASS: "/api/admin/checkout-bypass";
|
|
265
265
|
readonly MAINTENANCE_ANALYSIS: "/api/admin/maintenance/analysis";
|
|
266
266
|
readonly MAINTENANCE_CLOUD_LOGS: "/api/admin/maintenance/cloud-logs";
|
|
267
|
+
readonly CACHE_REVALIDATE: "/api/admin/cache/revalidate";
|
|
267
268
|
readonly MEDIA_LIST: "/api/admin/media";
|
|
268
269
|
};
|
|
269
270
|
export declare const AUCTION_ENDPOINTS: {
|
|
@@ -898,6 +899,7 @@ export declare const API_ENDPOINTS: {
|
|
|
898
899
|
readonly CHECKOUT_BYPASS: "/api/admin/checkout-bypass";
|
|
899
900
|
readonly MAINTENANCE_ANALYSIS: "/api/admin/maintenance/analysis";
|
|
900
901
|
readonly MAINTENANCE_CLOUD_LOGS: "/api/admin/maintenance/cloud-logs";
|
|
902
|
+
readonly CACHE_REVALIDATE: "/api/admin/cache/revalidate";
|
|
901
903
|
readonly MEDIA_LIST: "/api/admin/media";
|
|
902
904
|
};
|
|
903
905
|
readonly AUCTIONS: {
|
|
@@ -1497,6 +1499,7 @@ export declare const API_ROUTES: {
|
|
|
1497
1499
|
readonly CHECKOUT_BYPASS: "/api/admin/checkout-bypass";
|
|
1498
1500
|
readonly MAINTENANCE_ANALYSIS: "/api/admin/maintenance/analysis";
|
|
1499
1501
|
readonly MAINTENANCE_CLOUD_LOGS: "/api/admin/maintenance/cloud-logs";
|
|
1502
|
+
readonly CACHE_REVALIDATE: "/api/admin/cache/revalidate";
|
|
1500
1503
|
readonly MEDIA_LIST: "/api/admin/media";
|
|
1501
1504
|
};
|
|
1502
1505
|
readonly AUCTIONS: {
|
|
@@ -313,6 +313,17 @@ export const ADMIN_ENDPOINTS = {
|
|
|
313
313
|
CHECKOUT_BYPASS: "/api/admin/checkout-bypass",
|
|
314
314
|
MAINTENANCE_ANALYSIS: "/api/admin/maintenance/analysis",
|
|
315
315
|
MAINTENANCE_CLOUD_LOGS: "/api/admin/maintenance/cloud-logs",
|
|
316
|
+
/*
|
|
317
|
+
* Admin-session-gated cache invalidation.
|
|
318
|
+
*
|
|
319
|
+
* 🛑 Deliberately NOT `/api/cache/revalidate`, which authenticates on an
|
|
320
|
+
* `x-api-key` matching `CACHE_REVALIDATION_SECRET`. That secret belongs to
|
|
321
|
+
* the Firestore triggers and the seed CLI; shipping it to a browser so a
|
|
322
|
+
* button could call that endpoint would put a server secret in client code.
|
|
323
|
+
* This one carries no secret and is gated on the admin session like every
|
|
324
|
+
* other `/api/admin/*` route.
|
|
325
|
+
*/
|
|
326
|
+
CACHE_REVALIDATE: "/api/admin/cache/revalidate",
|
|
316
327
|
MEDIA_LIST: "/api/admin/media",
|
|
317
328
|
};
|
|
318
329
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site-wide "does this browse surface have anything in it" counters.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a sibling singleton and not a field on `siteSettings`
|
|
5
|
+
*
|
|
6
|
+
* `siteSettings` is admin-authored CONFIG and is in the tester's PRESERVE tier.
|
|
7
|
+
* This is DERIVED measurement, rewritten by a job. Mixing the two means a
|
|
8
|
+
* reconciler writing into the document that holds live API credentials, and it
|
|
9
|
+
* means a reseed or a settings restore silently reverting a count. Same split
|
|
10
|
+
* `categoryCounts` already makes against `categories`, and for the same
|
|
11
|
+
* Root Cause #92 reason: nothing triggers on this collection, so writing it
|
|
12
|
+
* can never re-enter the trigger that produced it.
|
|
13
|
+
*
|
|
14
|
+
* ## 🛑 ABSENT MEANS UNKNOWN, NEVER ZERO
|
|
15
|
+
*
|
|
16
|
+
* This is the single rule the whole file exists to enforce, and getting it
|
|
17
|
+
* backwards hides the entire catalogue.
|
|
18
|
+
*
|
|
19
|
+
* A key missing from either map means "not measured yet" — a fresh install, a
|
|
20
|
+
* failed job, a type added since the last run. Every reader must treat that as
|
|
21
|
+
* **visible**, exactly as `getLiveCategoryCounts` returns `undefined` rather
|
|
22
|
+
* than 0 so its callers fall back to `metrics`. Root Cause #72 is this
|
|
23
|
+
* project's record of the opposite choice: a "hide it when it's empty" rule
|
|
24
|
+
* whose count map did not cover an option, so "unknown" read as "zero" and the
|
|
25
|
+
* section vanished.
|
|
26
|
+
*
|
|
27
|
+
* `Partial` is therefore load-bearing typing, not convenience — an explicit
|
|
28
|
+
* zero and an absent key MUST stay distinguishable.
|
|
29
|
+
*
|
|
30
|
+
* ## 🛑 These are AVAILABLE rows, which is a time-dependent question
|
|
31
|
+
*
|
|
32
|
+
* The counts come from `listPublicProducts`, the one function every public
|
|
33
|
+
* browse page already queries through — so a surface can never be hidden while
|
|
34
|
+
* its own page would have rendered rows, which is the correctness property
|
|
35
|
+
* that matters here and the reason this is not a bespoke `count()` query.
|
|
36
|
+
*
|
|
37
|
+
* It inherits that function's time-dependence: an auction becomes unavailable
|
|
38
|
+
* by the clock with no product write, so a type whose last row just ended stays
|
|
39
|
+
* "non-empty" until the next recompute. Erring toward VISIBLE is the right
|
|
40
|
+
* direction for this feature — showing an empty list is a cosmetic problem,
|
|
41
|
+
* hiding a live one is a lost sale.
|
|
42
|
+
*
|
|
43
|
+
* @tag domain:products
|
|
44
|
+
* @tag layer:schema
|
|
45
|
+
* @tag access:isomorphic
|
|
46
|
+
*/
|
|
47
|
+
import type { ListingType } from "../types/index";
|
|
48
|
+
export declare const SURFACE_COUNTS_COLLECTION = "surfaceCounts";
|
|
49
|
+
/** Singleton — these are site-wide totals, so there is exactly one row. */
|
|
50
|
+
export declare const SURFACE_COUNTS_DOC_ID = "global";
|
|
51
|
+
/**
|
|
52
|
+
* Public browse surfaces that are NOT a listing type and so have no
|
|
53
|
+
* `listings.listingTypes` flag to hang off.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately just `reviews` today. A key here must have a real consumer —
|
|
56
|
+
* `/stores` and `/categories` already have their own `browsePages` flag and
|
|
57
|
+
* adding them here would be a second mechanism deciding one thing.
|
|
58
|
+
*/
|
|
59
|
+
export type CountedSurfaceKey = "reviews";
|
|
60
|
+
export interface SurfaceCountsDocument {
|
|
61
|
+
id: string;
|
|
62
|
+
/**
|
|
63
|
+
* Available listings per type. Absent key = UNKNOWN = show.
|
|
64
|
+
*
|
|
65
|
+
* A map rather than scalar fields: the listing-type union has grown twice
|
|
66
|
+
* (nine → ten with `spares`), and a map needs no schema change for the next.
|
|
67
|
+
*/
|
|
68
|
+
listingTypes: Partial<Record<ListingType, number>>;
|
|
69
|
+
/** Non-listing-type surfaces. Absent key = UNKNOWN = show. */
|
|
70
|
+
surfaces: Partial<Record<CountedSurfaceKey, number>>;
|
|
71
|
+
/**
|
|
72
|
+
* When the figures were taken. Read by the admin panel so an operator can
|
|
73
|
+
* tell "measured as empty an hour ago" from "never measured".
|
|
74
|
+
*/
|
|
75
|
+
updatedAt: Date;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Is this surface known to be empty?
|
|
79
|
+
*
|
|
80
|
+
* Returns `false` for an unmeasured surface — the caller must not be able to
|
|
81
|
+
* express "hide it" without a measurement that says so.
|
|
82
|
+
*/
|
|
83
|
+
export declare function isKnownEmpty(count: number | undefined): boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site-wide "does this browse surface have anything in it" counters.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a sibling singleton and not a field on `siteSettings`
|
|
5
|
+
*
|
|
6
|
+
* `siteSettings` is admin-authored CONFIG and is in the tester's PRESERVE tier.
|
|
7
|
+
* This is DERIVED measurement, rewritten by a job. Mixing the two means a
|
|
8
|
+
* reconciler writing into the document that holds live API credentials, and it
|
|
9
|
+
* means a reseed or a settings restore silently reverting a count. Same split
|
|
10
|
+
* `categoryCounts` already makes against `categories`, and for the same
|
|
11
|
+
* Root Cause #92 reason: nothing triggers on this collection, so writing it
|
|
12
|
+
* can never re-enter the trigger that produced it.
|
|
13
|
+
*
|
|
14
|
+
* ## 🛑 ABSENT MEANS UNKNOWN, NEVER ZERO
|
|
15
|
+
*
|
|
16
|
+
* This is the single rule the whole file exists to enforce, and getting it
|
|
17
|
+
* backwards hides the entire catalogue.
|
|
18
|
+
*
|
|
19
|
+
* A key missing from either map means "not measured yet" — a fresh install, a
|
|
20
|
+
* failed job, a type added since the last run. Every reader must treat that as
|
|
21
|
+
* **visible**, exactly as `getLiveCategoryCounts` returns `undefined` rather
|
|
22
|
+
* than 0 so its callers fall back to `metrics`. Root Cause #72 is this
|
|
23
|
+
* project's record of the opposite choice: a "hide it when it's empty" rule
|
|
24
|
+
* whose count map did not cover an option, so "unknown" read as "zero" and the
|
|
25
|
+
* section vanished.
|
|
26
|
+
*
|
|
27
|
+
* `Partial` is therefore load-bearing typing, not convenience — an explicit
|
|
28
|
+
* zero and an absent key MUST stay distinguishable.
|
|
29
|
+
*
|
|
30
|
+
* ## 🛑 These are AVAILABLE rows, which is a time-dependent question
|
|
31
|
+
*
|
|
32
|
+
* The counts come from `listPublicProducts`, the one function every public
|
|
33
|
+
* browse page already queries through — so a surface can never be hidden while
|
|
34
|
+
* its own page would have rendered rows, which is the correctness property
|
|
35
|
+
* that matters here and the reason this is not a bespoke `count()` query.
|
|
36
|
+
*
|
|
37
|
+
* It inherits that function's time-dependence: an auction becomes unavailable
|
|
38
|
+
* by the clock with no product write, so a type whose last row just ended stays
|
|
39
|
+
* "non-empty" until the next recompute. Erring toward VISIBLE is the right
|
|
40
|
+
* direction for this feature — showing an empty list is a cosmetic problem,
|
|
41
|
+
* hiding a live one is a lost sale.
|
|
42
|
+
*
|
|
43
|
+
* @tag domain:products
|
|
44
|
+
* @tag layer:schema
|
|
45
|
+
* @tag access:isomorphic
|
|
46
|
+
*/
|
|
47
|
+
export const SURFACE_COUNTS_COLLECTION = "surfaceCounts";
|
|
48
|
+
/** Singleton — these are site-wide totals, so there is exactly one row. */
|
|
49
|
+
export const SURFACE_COUNTS_DOC_ID = "global";
|
|
50
|
+
/**
|
|
51
|
+
* Is this surface known to be empty?
|
|
52
|
+
*
|
|
53
|
+
* Returns `false` for an unmeasured surface — the caller must not be able to
|
|
54
|
+
* express "hide it" without a measurement that says so.
|
|
55
|
+
*/
|
|
56
|
+
export function isKnownEmpty(count) {
|
|
57
|
+
return count === 0;
|
|
58
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2190,8 +2190,8 @@ export { getProductSortOptions } from "./features/products/components/ProductFil
|
|
|
2190
2190
|
export { getProductTableColumns } from "./features/products/columns/productTableColumns";
|
|
2191
2191
|
export { normalizeListingType, isAuctionListing, isPreOrderListing, isStandardListing, isPrizeDrawListing, isClassifiedListing, isDigitalCodeListing, isLiveListing, isArtListing, isStickersListing, } from "./features/products/index";
|
|
2192
2192
|
export { isFinalSale, acceptsChangeOfMindReturns, } from "./features/products/constants/final-sale";
|
|
2193
|
-
export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, ALL_LISTING_TYPES, isListingType, isBrowsePageEnabled, enabledBrowsePages, ALL_BROWSE_PAGES, isBrowsePageKey, } from "./_internal/shared/listing-types/feature-flags";
|
|
2194
|
-
export type { BrowsePageKey } from "./_internal/shared/listing-types/feature-flags";
|
|
2193
|
+
export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, ALL_LISTING_TYPES, isListingType, isBrowsePageEnabled, enabledBrowsePages, ALL_BROWSE_PAGES, isBrowsePageKey, isListingSurfaceVisible, isCountedSurfaceVisible, visibleListingTypes, } from "./_internal/shared/listing-types/feature-flags";
|
|
2194
|
+
export type { BrowsePageKey, SurfaceCountSnapshot, } from "./_internal/shared/listing-types/feature-flags";
|
|
2195
2195
|
export { ADVANCED_LISTING_TYPE_CAPABILITY, ADVANCED_LISTING_TYPES, BASIC_LISTING_TYPES, LISTING_TYPE_TIER, assertListingTypeAllowed, hasAdvancedListingTypes, isAdvancedListingType, isListingTypeAllowedFor, listingTypesAllowedFor, } from "./_internal/shared/listing-types/tiers";
|
|
2196
2196
|
export type { ListingTypeAccessContext, ListingTypeTier, } from "./_internal/shared/listing-types/tiers";
|
|
2197
2197
|
export { actionTracker, setActionTrackerSink, resetActionTrackerSink, type ActionEvent, type ActionTrackerSink, } from "./_internal/shared/listing-types/action-tracker";
|
package/dist/index.js
CHANGED
|
@@ -4083,7 +4083,9 @@ export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabl
|
|
|
4083
4083
|
// file's ALL_LISTING_TYPES note: `server-entry.ts` re-exports index, so a
|
|
4084
4084
|
// Server Component already reaches these from the bare specifier and a
|
|
4085
4085
|
// second path would leak an `_internal/` symbol through a third barrel.
|
|
4086
|
-
isBrowsePageEnabled, enabledBrowsePages, ALL_BROWSE_PAGES, isBrowsePageKey,
|
|
4086
|
+
isBrowsePageEnabled, enabledBrowsePages, ALL_BROWSE_PAGES, isBrowsePageKey,
|
|
4087
|
+
// Auto-hide gates — same barrel choice as the browse-section ones above.
|
|
4088
|
+
isListingSurfaceVisible, isCountedSurfaceVisible, visibleListingTypes, } from "./_internal/shared/listing-types/feature-flags.js";
|
|
4087
4089
|
// Basic/advanced listing-type split — which types a store may create.
|
|
4088
4090
|
// Pure (no firebase-admin in its import chain), so it is safe in index.ts.
|
|
4089
4091
|
export { ADVANCED_LISTING_TYPE_CAPABILITY, ADVANCED_LISTING_TYPES, BASIC_LISTING_TYPES, LISTING_TYPE_TIER, assertListingTypeAllowed, hasAdvancedListingTypes, isAdvancedListingType, isListingTypeAllowedFor, listingTypesAllowedFor, } from "./_internal/shared/listing-types/tiers.js";
|