@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.
Files changed (33) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/maintenance/index.d.ts +1 -0
  3. package/dist/_internal/client/features/maintenance/index.js +1 -0
  4. package/dist/_internal/client/features/maintenance/views/CacheInvalidationPanel.d.ts +33 -0
  5. package/dist/_internal/client/features/maintenance/views/CacheInvalidationPanel.js +96 -0
  6. package/dist/_internal/client/features/maintenance/views/MaintenanceDashboardView.js +2 -1
  7. package/dist/_internal/server/features/products/surface-counts.d.ts +26 -0
  8. package/dist/_internal/server/features/products/surface-counts.js +60 -0
  9. package/dist/_internal/server/features/seo/sitemap.js +37 -3
  10. package/dist/_internal/server/features/site-settings/adapters.d.ts +16 -0
  11. package/dist/_internal/server/features/site-settings/adapters.js +11 -0
  12. package/dist/_internal/server/jobs/core/onProductWrite.js +33 -0
  13. package/dist/_internal/server/jobs/core/surfaceCountsRollup.d.ts +55 -0
  14. package/dist/_internal/server/jobs/core/surfaceCountsRollup.js +128 -0
  15. package/dist/_internal/server/jobs/handlers/countersReconcile.js +54 -1
  16. package/dist/_internal/shared/actions/action-registry.js +28 -0
  17. package/dist/_internal/shared/cache/revalidate-targets.js +66 -47
  18. package/dist/_internal/shared/features/site-settings/types.d.ts +17 -0
  19. package/dist/_internal/shared/listing-types/feature-flags.d.ts +45 -0
  20. package/dist/_internal/shared/listing-types/feature-flags.js +51 -0
  21. package/dist/client.d.ts +4 -2
  22. package/dist/client.js +10 -1
  23. package/dist/constants/api-endpoints.d.ts +3 -0
  24. package/dist/constants/api-endpoints.js +11 -0
  25. package/dist/features/products/schemas/surface-counts.d.ts +83 -0
  26. package/dist/features/products/schemas/surface-counts.js +58 -0
  27. package/dist/index.d.ts +2 -2
  28. package/dist/index.js +3 -1
  29. package/dist/react/hooks/useListingSurfaceVisibility.d.ts +44 -0
  30. package/dist/react/hooks/useListingSurfaceVisibility.js +20 -0
  31. package/dist/server.d.ts +2 -0
  32. package/dist/server.js +12 -0
  33. 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
- export const countersReconcileHandler = runCountersReconcile;
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
- /** Detail route per listing type. Mirrors `ListingTypePlugin.detailRoute`. */
37
- const PRODUCT_DETAIL_BY_TYPE = {
38
- standard: (id) => `/products/${id}`,
39
- art: (id) => `/products/${id}`,
40
- stickers: (id) => `/products/${id}`,
41
- auction: (id) => `/auctions/${id}`,
42
- "pre-order": (id) => `/pre-orders/${id}`,
43
- "prize-draw": (id) => `/prize-draws/${id}`,
44
- classified: (id) => `/classified/${id}`,
45
- "digital-code": (id) => `/digital-codes/${id}`,
46
- live: (id) => `/live/${id}`,
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
- * The slug prefix system (CLAUDE.md § Slug Prefix System) makes this reliable
52
- * for seeded and generated ids — `auction-…`, `preorder-…`, `prizedraw-…` — and
53
- * it is only a fallback: the Firestore trigger reads the document and passes
54
- * `listingType` explicitly.
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
- if (id.startsWith("auction-"))
58
- return "auction";
59
- if (id.startsWith("preorder-"))
60
- return "pre-order";
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
- return out;
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
- if (type && PRODUCT_DETAIL_BY_TYPE[type]) {
83
- out.push(PRODUCT_DETAIL_BY_TYPE[type](id));
84
- // The type's own browse page, when it has one distinct from /products.
85
- const browse = PRODUCT_DETAIL_BY_TYPE[type](id).replace(/\/[^/]+$/, "");
86
- if (browse && browse !== ROUTES.PUBLIC.PRODUCTS)
87
- out.push(browse);
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 than
92
- * guessing one. At most nine entries marked stale, none of which renders
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 build of Object.values(PRODUCT_DETAIL_BY_TYPE))
96
- out.push(build(id));
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, } from "./_internal/shared/listing-types/feature-flags.js";
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, } from "./_internal/shared/listing-types/feature-flags.js";
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";