@mohasinac/appkit 4.66.0 → 4.68.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 +31 -2
- package/dist/_internal/server/features/site-settings/adapters.d.ts +16 -0
- package/dist/_internal/server/features/site-settings/adapters.js +21 -0
- package/dist/_internal/server/functions/firestore.d.ts +18 -1
- package/dist/_internal/server/functions/firestore.js +25 -1
- 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/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.d.ts +53 -0
- package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.js +63 -0
- 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 +39 -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/_internal/shared/listing-types/hidden-surfaces.d.ts +43 -0
- package/dist/_internal/shared/listing-types/hidden-surfaces.js +57 -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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which public BROWSE ROUTES must not be served, given the admin flags and the
|
|
3
|
+
* measured counts.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is one function and not two copies
|
|
6
|
+
*
|
|
7
|
+
* Two places need this answer and they must never disagree:
|
|
8
|
+
*
|
|
9
|
+
* · `buildSitemap` — decides which browse URLs are advertised.
|
|
10
|
+
* · `toPublicSiteSettings` → `src/proxy.ts` — decides which browse URLs 404.
|
|
11
|
+
*
|
|
12
|
+
* A disagreement is not cosmetic: the sitemap advertising a URL the edge 404s
|
|
13
|
+
* is a crawl error on a page we told Google to fetch, and the reverse serves a
|
|
14
|
+
* surface we have decided is closed. They were two hand-written copies of the
|
|
15
|
+
* same two loops when this was extracted.
|
|
16
|
+
*
|
|
17
|
+
* ## 🛑 EXACT PATHS, never prefixes
|
|
18
|
+
*
|
|
19
|
+
* `art`, `stickers`, `spares` and `standard` all detail at `/products/{slug}`,
|
|
20
|
+
* so a prefix rule would delist — or 404 — the entire standard catalogue the
|
|
21
|
+
* day `spares` emptied. An empty type contributes no detail URLs anyway, since
|
|
22
|
+
* its own fetcher returns none, so the exact-path rule loses nothing real.
|
|
23
|
+
*
|
|
24
|
+
* ## 🛑 A SHARED route survives while ANY of its types is visible
|
|
25
|
+
*
|
|
26
|
+
* `/art` spans `art` and `stickers`; `/products` spans `standard` plus several
|
|
27
|
+
* others. Without the second pass, hiding `stickers` alone would close a page
|
|
28
|
+
* `art` still fills — and hiding `spares` alone would close `/products`.
|
|
29
|
+
*
|
|
30
|
+
* @tag domain:products
|
|
31
|
+
* @tag layer:shared
|
|
32
|
+
* @tag access:isomorphic
|
|
33
|
+
* @tag sideEffects:none
|
|
34
|
+
*/
|
|
35
|
+
import { type SurfaceCountSnapshot } from "./feature-flags";
|
|
36
|
+
/** The snapshot shape `isListingSurfaceVisible` reads its admin flags from. */
|
|
37
|
+
interface SettingsSnapshot {
|
|
38
|
+
listings?: {
|
|
39
|
+
listingTypes?: Partial<Record<string, boolean>>;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export declare function hiddenSurfaceRoutes(settings: SettingsSnapshot | null | undefined, counts: SurfaceCountSnapshot | null | undefined): string[];
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which public BROWSE ROUTES must not be served, given the admin flags and the
|
|
3
|
+
* measured counts.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is one function and not two copies
|
|
6
|
+
*
|
|
7
|
+
* Two places need this answer and they must never disagree:
|
|
8
|
+
*
|
|
9
|
+
* · `buildSitemap` — decides which browse URLs are advertised.
|
|
10
|
+
* · `toPublicSiteSettings` → `src/proxy.ts` — decides which browse URLs 404.
|
|
11
|
+
*
|
|
12
|
+
* A disagreement is not cosmetic: the sitemap advertising a URL the edge 404s
|
|
13
|
+
* is a crawl error on a page we told Google to fetch, and the reverse serves a
|
|
14
|
+
* surface we have decided is closed. They were two hand-written copies of the
|
|
15
|
+
* same two loops when this was extracted.
|
|
16
|
+
*
|
|
17
|
+
* ## 🛑 EXACT PATHS, never prefixes
|
|
18
|
+
*
|
|
19
|
+
* `art`, `stickers`, `spares` and `standard` all detail at `/products/{slug}`,
|
|
20
|
+
* so a prefix rule would delist — or 404 — the entire standard catalogue the
|
|
21
|
+
* day `spares` emptied. An empty type contributes no detail URLs anyway, since
|
|
22
|
+
* its own fetcher returns none, so the exact-path rule loses nothing real.
|
|
23
|
+
*
|
|
24
|
+
* ## 🛑 A SHARED route survives while ANY of its types is visible
|
|
25
|
+
*
|
|
26
|
+
* `/art` spans `art` and `stickers`; `/products` spans `standard` plus several
|
|
27
|
+
* others. Without the second pass, hiding `stickers` alone would close a page
|
|
28
|
+
* `art` still fills — and hiding `spares` alone would close `/products`.
|
|
29
|
+
*
|
|
30
|
+
* @tag domain:products
|
|
31
|
+
* @tag layer:shared
|
|
32
|
+
* @tag access:isomorphic
|
|
33
|
+
* @tag sideEffects:none
|
|
34
|
+
*/
|
|
35
|
+
import { ALL_LISTING_TYPES, isListingSurfaceVisible, } from "./feature-flags.js";
|
|
36
|
+
import { pluginFor } from "./_registry.js";
|
|
37
|
+
export function hiddenSurfaceRoutes(settings, counts) {
|
|
38
|
+
const hidden = new Set();
|
|
39
|
+
const routeOf = (type) => String(pluginFor(type).browseRoute ?? "");
|
|
40
|
+
for (const type of ALL_LISTING_TYPES) {
|
|
41
|
+
if (isListingSurfaceVisible(type, settings, counts))
|
|
42
|
+
continue;
|
|
43
|
+
const route = routeOf(type);
|
|
44
|
+
if (route)
|
|
45
|
+
hidden.add(route);
|
|
46
|
+
}
|
|
47
|
+
/*
|
|
48
|
+
* Second pass, deliberately not folded into the first: a route is only
|
|
49
|
+
* hidden once EVERY type that browses there is hidden, and the first pass
|
|
50
|
+
* cannot know that until it has seen them all.
|
|
51
|
+
*/
|
|
52
|
+
for (const type of ALL_LISTING_TYPES) {
|
|
53
|
+
if (isListingSurfaceVisible(type, settings, counts))
|
|
54
|
+
hidden.delete(routeOf(type));
|
|
55
|
+
}
|
|
56
|
+
return [...hidden];
|
|
57
|
+
}
|
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";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ListingType } from "../../features/products/types/index";
|
|
2
|
+
/**
|
|
3
|
+
* Which public browse surfaces should be LINKED — the admin flag, minus the
|
|
4
|
+
* ones measured as having nothing in them.
|
|
5
|
+
*
|
|
6
|
+
* ## 🛑 Why this is not a change to `useListingTypeFlags`
|
|
7
|
+
*
|
|
8
|
+
* That hook answers a different question — *"does this marketplace sell X?"* —
|
|
9
|
+
* and its main consumer is the seller's type dropdown. A seller must still be
|
|
10
|
+
* able to list the first spare part when there are zero spares, so folding
|
|
11
|
+
* emptiness into it would make the catalogue unfillable the moment it emptied:
|
|
12
|
+
* the only control that could add stock would disappear for want of stock.
|
|
13
|
+
* Same split as `enabledListingTypes` vs `visibleListingTypes`.
|
|
14
|
+
*
|
|
15
|
+
* So: `useListingTypeFlags` for anything AUTHORING, this for anything
|
|
16
|
+
* BROWSING.
|
|
17
|
+
*
|
|
18
|
+
* ## 🛑 Defaults to VISIBLE while settings load
|
|
19
|
+
*
|
|
20
|
+
* Same reasoning as `useBrowsePageFlags`, and it bites harder here. This hook
|
|
21
|
+
* decides whether a nav entry renders, and `surfaceCounts` is absent from the
|
|
22
|
+
* payload until the rollup has run at least once — so a restrictive default
|
|
23
|
+
* would blank most of the navbar on every cold load, and would blank it
|
|
24
|
+
* permanently on an install whose job has never run.
|
|
25
|
+
*
|
|
26
|
+
* `isListingSurfaceVisible` encodes the rule (absent count → visible); this
|
|
27
|
+
* hook only supplies it with data. Do not re-derive the comparison here.
|
|
28
|
+
*/
|
|
29
|
+
export type ListingSurfaceVisibility = Record<ListingType, boolean> & {
|
|
30
|
+
/** True when the type's browse surface should be linked. */
|
|
31
|
+
isVisible: (type: ListingType) => boolean;
|
|
32
|
+
/**
|
|
33
|
+
* True when a non-listing-type surface (`"reviews"`) should be linked.
|
|
34
|
+
* Absent measurement → true.
|
|
35
|
+
*/
|
|
36
|
+
isSurfaceVisible: (surface: string) => boolean;
|
|
37
|
+
/**
|
|
38
|
+
* True when a link may be shown — it points at no listing-type surface, or
|
|
39
|
+
* at one that is visible. Pass an href straight from a nav/footer constant,
|
|
40
|
+
* together with the href→types map the caller owns.
|
|
41
|
+
*/
|
|
42
|
+
allowsTypes: (types: readonly ListingType[] | undefined) => boolean;
|
|
43
|
+
};
|
|
44
|
+
export declare function useListingSurfaceVisibility(): ListingSurfaceVisibility;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useSiteSettings } from "../../core/hooks/useSiteSettings.js";
|
|
3
|
+
import { ALL_LISTING_TYPES, isListingSurfaceVisible, isCountedSurfaceVisible, } from "../../_internal/shared/listing-types/feature-flags.js";
|
|
4
|
+
export function useListingSurfaceVisibility() {
|
|
5
|
+
const { data } = useSiteSettings();
|
|
6
|
+
const settings = { listings: { listingTypes: data?.listings?.listingTypes } };
|
|
7
|
+
const counts = data?.surfaceCounts;
|
|
8
|
+
const flags = Object.fromEntries(ALL_LISTING_TYPES.map((t) => [t, isListingSurfaceVisible(t, settings, counts)]));
|
|
9
|
+
return {
|
|
10
|
+
...flags,
|
|
11
|
+
isVisible: (type) => flags[type],
|
|
12
|
+
isSurfaceVisible: (surface) => isCountedSurfaceVisible(surface, counts),
|
|
13
|
+
/*
|
|
14
|
+
* ANY of the types, not every — matching the existing footer rule, whose
|
|
15
|
+
* comment states it: "/art stays reachable when stickers are off and art
|
|
16
|
+
* is on." A shared page is empty only when every type it spans is.
|
|
17
|
+
*/
|
|
18
|
+
allowsTypes: (types) => !types || types.length === 0 || types.some((t) => flags[t]),
|
|
19
|
+
};
|
|
20
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -662,3 +662,5 @@ export { AD_BOOKING_COLLECTION, AD_BOOKING_PAYMENT_STATES, AD_MODERATION_STATES,
|
|
|
662
662
|
export type { AdBookingDocument, AdBookingCreative, AdBookingPaymentState, AdModerationState, AdPeriod, } from "./features/ads/schemas/firestore";
|
|
663
663
|
export { getDashboardHubData } from "./_internal/server/features/dashboard/data";
|
|
664
664
|
export type { DashboardHubData } from "./_internal/server/features/dashboard/data";
|
|
665
|
+
export { getSurfaceCounts } from "./_internal/server/features/products/surface-counts";
|
|
666
|
+
export type { PublicSurfaceCounts } from "./_internal/server/features/products/surface-counts";
|
package/dist/server.js
CHANGED
|
@@ -1872,3 +1872,15 @@ export { AD_BOOKING_COLLECTION, AD_BOOKING_PAYMENT_STATES, AD_MODERATION_STATES,
|
|
|
1872
1872
|
* above reaches a consumer route.
|
|
1873
1873
|
*/
|
|
1874
1874
|
export { getDashboardHubData } from "./_internal/server/features/dashboard/data.js";
|
|
1875
|
+
// Auto-hide: the measured "does this browse surface hold anything" document.
|
|
1876
|
+
// Server-only (firebase-admin via getAdminDb); the PREDICATE that reads it is
|
|
1877
|
+
// client-safe and lives in client.ts/index.ts beside the other visibility gates.
|
|
1878
|
+
export { getSurfaceCounts } from "./_internal/server/features/products/surface-counts.js";
|
|
1879
|
+
/*
|
|
1880
|
+
* 🛑 `runSurfaceCountsRollup` / `computeSurfaceCounts` are deliberately NOT
|
|
1881
|
+
* exported. They live under `_internal/server/jobs/core/` and their only
|
|
1882
|
+
* caller is `countersReconcileHandler`, which imports them directly — so a
|
|
1883
|
+
* barrel entry would leak an `_internal/` symbol for no consumer, which
|
|
1884
|
+
* `audit-appkit-reexports` correctly blocks. Add one only if a consumer
|
|
1885
|
+
* genuinely needs to trigger a rollup from outside appkit.
|
|
1886
|
+
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mohasinac/appkit",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.68.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -23,6 +23,18 @@
|
|
|
23
23
|
"types": "./dist/client.d.ts",
|
|
24
24
|
"import": "./dist/client.js"
|
|
25
25
|
},
|
|
26
|
+
"./client/*": {
|
|
27
|
+
"types": "./dist/client/*.d.ts",
|
|
28
|
+
"import": "./dist/client/*.js"
|
|
29
|
+
},
|
|
30
|
+
"./hand-mode": {
|
|
31
|
+
"types": "./dist/hand-mode/index.d.ts",
|
|
32
|
+
"import": "./dist/hand-mode/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./theme": {
|
|
35
|
+
"types": "./dist/theme/index.d.ts",
|
|
36
|
+
"import": "./dist/theme/index.js"
|
|
37
|
+
},
|
|
26
38
|
"./next": {
|
|
27
39
|
"types": "./dist/next/index.d.ts",
|
|
28
40
|
"import": "./dist/next/index.js"
|
|
@@ -281,6 +293,26 @@
|
|
|
281
293
|
"./views/tour-provider": {
|
|
282
294
|
"types": "./dist/_internal/client/features/tour/TourProvider.d.ts",
|
|
283
295
|
"import": "./dist/_internal/client/features/tour/TourProvider.js"
|
|
296
|
+
},
|
|
297
|
+
"./shipping/quote": {
|
|
298
|
+
"types": "./dist/_internal/shared/shipping/quote.d.ts",
|
|
299
|
+
"import": "./dist/_internal/shared/shipping/quote.js"
|
|
300
|
+
},
|
|
301
|
+
"./fees/calculator": {
|
|
302
|
+
"types": "./dist/_internal/shared/fees/calculator.d.ts",
|
|
303
|
+
"import": "./dist/_internal/shared/fees/calculator.js"
|
|
304
|
+
},
|
|
305
|
+
"./emi/choice": {
|
|
306
|
+
"types": "./dist/_internal/shared/features/emi/choice.d.ts",
|
|
307
|
+
"import": "./dist/_internal/shared/features/emi/choice.js"
|
|
308
|
+
},
|
|
309
|
+
"./layout/nav-portal": {
|
|
310
|
+
"types": "./dist/_internal/shared/features/layout/types.d.ts",
|
|
311
|
+
"import": "./dist/_internal/shared/features/layout/types.js"
|
|
312
|
+
},
|
|
313
|
+
"./views/digital-content-pool": {
|
|
314
|
+
"types": "./dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts",
|
|
315
|
+
"import": "./dist/_internal/client/features/digital-code/DigitalContentPoolManager.js"
|
|
284
316
|
}
|
|
285
317
|
},
|
|
286
318
|
"files": [
|