@mohasinac/appkit 4.68.0 → 4.69.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/server/features/seo/sitemap.js +20 -1
- package/dist/_internal/server/features/site-settings/adapters.js +18 -1
- package/dist/_internal/shared/features/site-settings/types.d.ts +24 -0
- package/dist/_internal/shared/listing-types/feature-flags.d.ts +12 -0
- package/dist/_internal/shared/listing-types/feature-flags.js +14 -0
- package/dist/_internal/shared/listing-types/hidden-surfaces.d.ts +83 -14
- package/dist/_internal/shared/listing-types/hidden-surfaces.js +127 -14
- package/dist/_internal/shared/listing-types/route-gating.d.ts +67 -25
- package/dist/_internal/shared/listing-types/route-gating.js +71 -27
- package/dist/features/about/components/HelpPageView.js +19 -1
- package/dist/features/admin/schemas/firestore.d.ts +23 -0
- package/dist/features/admin/schemas/firestore.js +63 -8
- package/dist/features/homepage/components/MarketplaceHomepageView.js +66 -4
- package/dist/features/homepage/lib/section-gating.d.ts +84 -0
- package/dist/features/homepage/lib/section-gating.js +224 -0
- package/dist/features/homepage/lib/slide-gating.d.ts +14 -14
- package/dist/features/homepage/lib/slide-gating.js +21 -15
- package/dist/features/products/components/CardStoreLine.js +23 -0
- package/dist/features/products/components/ProductDetailPageView.js +30 -3
- package/package.json +1 -1
|
@@ -17,27 +17,22 @@
|
|
|
17
17
|
* type-keyed gate could see them — the only thing that identifies them is
|
|
18
18
|
* where they point.
|
|
19
19
|
*
|
|
20
|
-
* ##
|
|
20
|
+
* ## Where the rules are written down
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* On `isClosedHref` below — the three axes it reads (listing-type flags ANDed
|
|
23
|
+
* with measured counts, closed browse sections, hand-disabled routes), what it
|
|
24
|
+
* deliberately does not match, and the production measurement that forced each
|
|
25
|
+
* one. Stated there and not restated here deliberately: two comments
|
|
26
|
+
* disagreeing about what a gate matches is a defect this codebase keeps paying
|
|
27
|
+
* for, and the only safe number of descriptions is one.
|
|
26
28
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `/how-auctions-work`, `/seller-guide/prize-draws`. None of them starts with
|
|
31
|
-
* `/auctions`, so prefix matching excludes them for free, and that is correct:
|
|
32
|
-
* an explainer about auctions is still honest reading when auctions are off.
|
|
33
|
-
* A substring test would have eaten all three.
|
|
34
|
-
*
|
|
35
|
-
* Store tabs (`/stores/{slug}/auctions`) are also out of scope — those are
|
|
36
|
-
* resolved by `resolveStoreTabs`, which owns its own visibility, and a second
|
|
37
|
-
* owner is how a mirror drifts (Root Cause #42).
|
|
29
|
+
* Store tabs (`/stores/{slug}/auctions`) are out of scope — those are resolved
|
|
30
|
+
* by `resolveStoreTabs`, which owns its own visibility, and a second owner is
|
|
31
|
+
* how a mirror drifts (Root Cause #42).
|
|
38
32
|
*/
|
|
39
33
|
import { LISTING_TYPE_REGISTRY } from "./_registry.js";
|
|
40
|
-
import { ALL_LISTING_TYPES,
|
|
34
|
+
import { ALL_LISTING_TYPES, isListingSurfaceVisible, } from "./feature-flags.js";
|
|
35
|
+
import { closedSurfacePrefixes, isUnderClosedPrefix } from "./hidden-surfaces.js";
|
|
41
36
|
/**
|
|
42
37
|
* browse route → every listing type that browses there.
|
|
43
38
|
*
|
|
@@ -88,27 +83,76 @@ function normalisePath(href) {
|
|
|
88
83
|
// Trailing slash off, but never reduce "/" to "".
|
|
89
84
|
return delocalised.length > 1 ? delocalised.replace(/\/+$/, "") : delocalised;
|
|
90
85
|
}
|
|
86
|
+
export function closedSurfaceContext(settings, counts) {
|
|
87
|
+
return { settings, counts, prefixes: closedSurfacePrefixes(settings) };
|
|
88
|
+
}
|
|
91
89
|
/**
|
|
92
|
-
* `true` when `href`
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
90
|
+
* `true` when `href` leads somewhere this site has closed.
|
|
91
|
+
*
|
|
92
|
+
* ## THREE axes, because authored content can point at any of them
|
|
93
|
+
*
|
|
94
|
+
* This replaced `isDisabledListingHref`, which read `listings.listingTypes`
|
|
95
|
+
* and nothing else — and the two axes it could not see were the two doing
|
|
96
|
+
* visible damage. Measured on the live homepage, 2026-10-27:
|
|
97
|
+
*
|
|
98
|
+
* · **counts.** `art` and `spares` had flag `true` and measured count `0`,
|
|
99
|
+
* so they were auto-hidden everywhere — `/art` and `/spares` both answered
|
|
100
|
+
* a real **404** at the edge — while the homepage went on shipping 3 links
|
|
101
|
+
* to each from the "Shop extras" cards. `isListingTypeEnabled` reads the
|
|
102
|
+
* flag only, so it called both of them enabled. `isListingSurfaceVisible`
|
|
103
|
+
* ANDs the flag with the count, which is the rule every other surface on
|
|
104
|
+
* the site already uses.
|
|
105
|
+
* · **browse sections.** All four had been `false` since 2026-10-24, and the
|
|
106
|
+
* homepage carried **34** `/categories` links across the welcome hero's
|
|
107
|
+
* secondary CTA, the 35-button systems matrix and the 4-banner generation
|
|
108
|
+
* promo grid.
|
|
109
|
+
*
|
|
110
|
+
* ## Deliberately link-based, never keyword-based
|
|
111
|
+
*
|
|
112
|
+
* Unchanged from the original, and still the point: nothing here reads copy.
|
|
113
|
+
* "Win it at your price" names no type, so a word search would miss it, and it
|
|
114
|
+
* would also eat the authenticity slide for saying "bid" in passing. A
|
|
115
|
+
* destination is checkable; prose is not.
|
|
116
|
+
*
|
|
117
|
+
* ## What it still does NOT match
|
|
118
|
+
*
|
|
119
|
+
* Editorial pages that merely carry a name in the path — `/help/auctions`,
|
|
120
|
+
* `/how-auctions-work`, `/seller-guide/prize-draws`. None starts with
|
|
121
|
+
* `/auctions`, so the `/`-delimited boundary excludes them for free, and that
|
|
122
|
+
* is correct: an explainer about auctions is honest reading when auctions are
|
|
123
|
+
* off.
|
|
124
|
+
*
|
|
125
|
+
* Fails OPEN on anything it cannot classify — an unknown path, an external
|
|
126
|
+
* URL, a missing settings document. A settings read that errored must not
|
|
127
|
+
* strip working destinations off the homepage (Root Cause #72).
|
|
100
128
|
*/
|
|
101
|
-
export function
|
|
129
|
+
export function isClosedHref(href, ctx) {
|
|
102
130
|
const path = normalisePath(href);
|
|
103
131
|
if (!path)
|
|
104
132
|
return false;
|
|
133
|
+
// Closed browse sections and hand-disabled routes, descendants included.
|
|
134
|
+
if (isUnderClosedPrefix(path, ctx.prefixes))
|
|
135
|
+
return true;
|
|
105
136
|
for (const [route, types] of TYPES_BY_BROWSE_ROUTE) {
|
|
106
137
|
// The browse page itself, or a detail page under it. `startsWith(route)`
|
|
107
138
|
// alone would match `/live` against `/livestream`, so the boundary is
|
|
108
139
|
// explicit.
|
|
109
140
|
if (path !== route && !path.startsWith(`${route}/`))
|
|
110
141
|
continue;
|
|
111
|
-
|
|
142
|
+
/*
|
|
143
|
+
* 🛑 `every`, not `some`, and `isListingSurfaceVisible`, not
|
|
144
|
+
* `isListingTypeEnabled`.
|
|
145
|
+
*
|
|
146
|
+
* `every`: `art` and `stickers` share `/art`, so a site selling art but
|
|
147
|
+
* not stickers must keep `/art` reachable. One closed claimant is not
|
|
148
|
+
* enough — treating it as enough silently deletes a working destination.
|
|
149
|
+
*
|
|
150
|
+
* `isListingSurfaceVisible`: it ANDs the admin flag with the measured
|
|
151
|
+
* count, which is what makes the auto-hidden-because-empty case visible
|
|
152
|
+
* here. Reading the flag alone is exactly how the live homepage kept
|
|
153
|
+
* advertising `/art` and `/spares` after both began 404ing.
|
|
154
|
+
*/
|
|
155
|
+
return types.every((type) => !isListingSurfaceVisible(type, ctx.settings, ctx.counts));
|
|
112
156
|
}
|
|
113
157
|
return false;
|
|
114
158
|
}
|
|
@@ -10,6 +10,9 @@ import { Row, Stack } from "../../../ui/components/Layout.js";
|
|
|
10
10
|
import { Section } from "../../../ui/components/Semantic.js";
|
|
11
11
|
import { TextLink } from "../../../ui/components/TextLink.js";
|
|
12
12
|
import { BookOpen, MessageCircle, ShoppingBag, Gavel, PackageCheck, UserCheck, Store, ShieldCheck, ChevronRight, } from "lucide-react";
|
|
13
|
+
import { closedSurfaceContext, isClosedHref, } from "../../../_internal/shared/listing-types/route-gating.js";
|
|
14
|
+
import { getSiteSettingsGlobal } from "../../admin/utils/getSiteSettingsGlobal.js";
|
|
15
|
+
import { safeRead } from "../../../errors/safe-read.js";
|
|
13
16
|
const __P = {
|
|
14
17
|
p6: "p-[var(--appkit-space-6)]",
|
|
15
18
|
p8: "p-[var(--appkit-space-8)]",
|
|
@@ -19,6 +22,21 @@ export async function HelpPageView({} = {}) {
|
|
|
19
22
|
const flex = { row: FLEX_ROW };
|
|
20
23
|
const { getTranslations } = await import("next-intl/server");
|
|
21
24
|
const t = await getTranslations("helpPage");
|
|
25
|
+
/*
|
|
26
|
+
* 🛑 THE HELP INDEX IS A HARDCODED LINK LIST, which is the same blind spot
|
|
27
|
+
* the homepage had: nothing here knows that `/help/seller-badges` and
|
|
28
|
+
* `/seller-guide` are now closed, so two of its seven tiles pointed at a
|
|
29
|
+
* 404 — on the page a confused buyer goes to when something has already
|
|
30
|
+
* gone wrong.
|
|
31
|
+
*
|
|
32
|
+
* Filtered through the one closed-surface rule rather than by deleting the
|
|
33
|
+
* entries: a withdrawn surface is a flag, so the tile returns on its own if
|
|
34
|
+
* the flag is flipped back.
|
|
35
|
+
*/
|
|
36
|
+
const settings = await safeRead(() => getSiteSettingsGlobal(), {
|
|
37
|
+
route: "/help", key: "help.siteSettings", fallback: null,
|
|
38
|
+
});
|
|
39
|
+
const closedSurfaces = closedSurfaceContext(settings, undefined);
|
|
22
40
|
const TOPICS = [
|
|
23
41
|
{
|
|
24
42
|
icon: ShoppingBag,
|
|
@@ -77,7 +95,7 @@ export async function HelpPageView({} = {}) {
|
|
|
77
95
|
iconColor: "text-rose-600 dark:text-rose-400",
|
|
78
96
|
},
|
|
79
97
|
];
|
|
80
|
-
return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [_jsx(ProseHero, { title: t("title"), subtitle: t("subtitle") }), _jsxs(Stack, { gap: "14", className: `${page.container.md}`, padding: "content-banner", children: [renderTopicsGrid(t, flex, TOPICS), renderScamAwarenessAlert(t), renderTrackOrderSection(t), renderContactCtaSection(t)] })] }));
|
|
98
|
+
return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [_jsx(ProseHero, { title: t("title"), subtitle: t("subtitle") }), _jsxs(Stack, { gap: "14", className: `${page.container.md}`, padding: "content-banner", children: [renderTopicsGrid(t, flex, TOPICS.filter((x) => !isClosedHref(x.href, closedSurfaces))), renderScamAwarenessAlert(t), renderTrackOrderSection(t), renderContactCtaSection(t)] })] }));
|
|
81
99
|
}
|
|
82
100
|
function renderTopicsGrid(t, flex, topics) {
|
|
83
101
|
return (_jsxs(Section, { children: [_jsx(Heading, { level: 2, className: "mb-6", align: "center", children: t("browseTopics") }), _jsx(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-2 lg:grid-cols-3", children: topics.map(({ icon: Icon, title, desc, href, color, iconColor }) => (_jsxs(TextLink, { rounded: "xl", href: href, shadow: "hover-md", className: `group border p-[1.25rem] transition ${color} no-underline`, children: [_jsx(Row, { align: "center", justify: "center", className: `w-10 h-10 mb-3`, surface: "default", rounded: "lg", children: _jsx(Icon, { className: `w-5 h-5 ${iconColor}` }) }), _jsxs(Div, { layout: "flex", justify: "between", align: "start", className: flex.row, children: [_jsxs(Div, { children: [_jsx(Text, { className: "mb-1", weight: "semibold", children: title }), _jsx(Text, { variant: "secondary", className: "leading-relaxed", size: "sm", children: desc })] }), _jsx(ChevronRight, { className: "w-4 h-4 mt-1 flex-shrink-0 opacity-40 group-hover:opacity-80 transition-opacity" })] })] }, href))) })] }));
|
|
@@ -780,6 +780,29 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
780
780
|
* Add a new section THERE, not here.
|
|
781
781
|
*/
|
|
782
782
|
browsePages?: Partial<Record<string, boolean>>;
|
|
783
|
+
/**
|
|
784
|
+
* Public paths withdrawn wholesale — closed including every descendant.
|
|
785
|
+
*
|
|
786
|
+
* 🛑 THE FOURTH AXIS, and it exists because the other three cannot express
|
|
787
|
+
* "this FEATURE is off". `listingTypes` closes a type's browse page,
|
|
788
|
+
* `browsePages` closes a taxonomy directory, `categoryTypes` closes
|
|
789
|
+
* bundles — and none of them reaches `/seller-guide`, `/fees`,
|
|
790
|
+
* `/how-payouts-work`, `/item-requests` or `/scams`.
|
|
791
|
+
*
|
|
792
|
+
* 🛑 NOT the same as `disabledRoutes`, and the difference is the whole
|
|
793
|
+
* point. That array is DERIVED: `updateNavConfigDomain` rebuilds it from
|
|
794
|
+
* `navConfig ∩ ALL_PUBLIC_NAV_ITEMS` on every nav toggle, so a path that
|
|
795
|
+
* is not a nav item CANNOT survive there — append one by hand and the next
|
|
796
|
+
* admin toggle silently re-opens it (Root Cause #42). Most of what a
|
|
797
|
+
* single-vendor storefront needs to withdraw is footer-only, so it has no
|
|
798
|
+
* nav item to hang off and this is the only durable home for it.
|
|
799
|
+
*
|
|
800
|
+
* Prefix-matched on a `/`-delimited boundary by `closedSurfacePrefixes`,
|
|
801
|
+
* so `/scams` takes `/scams/report` and `/scams/faqs` with it. Nothing is
|
|
802
|
+
* deleted: every route, page and `ROUTES.PUBLIC.*` constant still exists,
|
|
803
|
+
* and removing an entry re-opens it.
|
|
804
|
+
*/
|
|
805
|
+
closedPaths?: string[];
|
|
783
806
|
};
|
|
784
807
|
/**
|
|
785
808
|
* Admin-authored HTML overrides for the public policy pages. When a key holds
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* Admin Feature Firestore Document Types & Constants
|
|
3
3
|
* Covers: notifications, chat rooms, site settings
|
|
4
4
|
*/
|
|
5
|
+
/* Pure constants module with no firebase-admin in its import chain, so this
|
|
6
|
+
* stays safe in the client bundle (appkit Export Rules). Same reasoning as
|
|
7
|
+
* the ROUTES import in `_internal/shared/listing-types/feature-flags.ts`. */
|
|
8
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
5
9
|
import { DEFAULT_AUCTION_BID_INCREMENT_TIERS } from "../../../_internal/shared/features/auctions/config.js";
|
|
6
10
|
// Flag-key constants. Consumers MUST reference the bypass flag via this
|
|
7
11
|
// constant so audit-checkout-bypass rule 1 (substring scan) only sees a
|
|
@@ -285,9 +289,14 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
285
289
|
whatsappNotifyFeeEnabled: false,
|
|
286
290
|
whatsappNotifyFee: 10,
|
|
287
291
|
whatsappNotifyMonthlyFee: 199,
|
|
288
|
-
|
|
292
|
+
/* Opt-in buyer UPSELLS, off since 2026-10-27 — gift wrap and shipment
|
|
293
|
+
* protection are marketplace merchandising, not "the basic of the basic
|
|
294
|
+
* for an ecommerce". Disabling an opt-in extra removes an OPTION, so no
|
|
295
|
+
* total can go up. `platformFeePercent` is deliberately NOT touched: that
|
|
296
|
+
* changes what every buyer pays and is a pricing decision. */
|
|
297
|
+
giftWrapFeeEnabled: false,
|
|
289
298
|
giftWrapFee: 49,
|
|
290
|
-
shipmentProtectionFeeEnabled:
|
|
299
|
+
shipmentProtectionFeeEnabled: false,
|
|
291
300
|
shipmentProtectionFeePercent: 2,
|
|
292
301
|
shipmentProtectionFeeMin: 30,
|
|
293
302
|
saleProtectionFeeEnabled: true,
|
|
@@ -348,13 +357,22 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
348
357
|
* all-true map, and changing this file does NOT rewrite it — flip those in the
|
|
349
358
|
* admin UI, or re-seed.
|
|
350
359
|
*/
|
|
360
|
+
/*
|
|
361
|
+
* TWO types, as of 2026-10-27 — buy-it-now and reserve, nothing else.
|
|
362
|
+
*
|
|
363
|
+
* 🛑 MUST STAY IDENTICAL to `siteSettingsSeedData.listings`, which says the
|
|
364
|
+
* same thing about this block. They had already drifted: `bundle` was
|
|
365
|
+
* `false` here and `true` in the seed, so a freshly seeded site shipped the
|
|
366
|
+
* featured-bundles rail and a never-configured one did not — the exact
|
|
367
|
+
* disagreement both comments exist to forbid. Change them together.
|
|
368
|
+
*/
|
|
351
369
|
listings: {
|
|
352
370
|
listingTypes: {
|
|
353
371
|
standard: true,
|
|
354
|
-
spares: true,
|
|
355
372
|
"pre-order": true,
|
|
356
|
-
|
|
357
|
-
|
|
373
|
+
spares: false,
|
|
374
|
+
art: false,
|
|
375
|
+
stickers: false,
|
|
358
376
|
auction: false,
|
|
359
377
|
"prize-draw": false,
|
|
360
378
|
classified: false,
|
|
@@ -362,9 +380,11 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
362
380
|
live: false,
|
|
363
381
|
},
|
|
364
382
|
// `bundle: false` is the admin-side switch for bundles, which are a
|
|
365
|
-
// CATEGORY type rather than a listing type — it
|
|
366
|
-
//
|
|
367
|
-
//
|
|
383
|
+
// CATEGORY type rather than a listing type — it hides the homepage's
|
|
384
|
+
// featured-bundles rail and the bundle tabs on category and brand pages,
|
|
385
|
+
// and since 2026-10-27 it is also what closes `/bundles` and
|
|
386
|
+
// `/bundles/{slug}` at the edge via `closedSurfacePrefixes`. Same
|
|
387
|
+
// reasoning as the eight listing types above.
|
|
368
388
|
categoryTypes: {
|
|
369
389
|
category: true,
|
|
370
390
|
sublisting: true,
|
|
@@ -391,6 +411,41 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
391
411
|
franchise: false,
|
|
392
412
|
stores: false,
|
|
393
413
|
},
|
|
414
|
+
/*
|
|
415
|
+
* Marketplace FEATURES withdrawn — the fourth axis, 2026-10-27.
|
|
416
|
+
*
|
|
417
|
+
* Owner: reduce a multi-seller marketplace to a plain single-vendor shop.
|
|
418
|
+
* These are the surfaces whose whole premise is the marketplace — seller
|
|
419
|
+
* onboarding, the commission schedule, the payout timetable, the scam
|
|
420
|
+
* registry, the reference tools. `closedPaths` is prefix-matched, so
|
|
421
|
+
* `/scams` takes `/scams/report` and `/scams/faqs` with it.
|
|
422
|
+
*
|
|
423
|
+
* 🛑 NOT in this list, on purpose: `/help`, `/faqs`, `/track`, `/gst`,
|
|
424
|
+
* `/credits`. They are merely no longer advertised. Closing means 404, and
|
|
425
|
+
* 404ing a support page someone has bookmarked is a regression with no
|
|
426
|
+
* upside — unlinking it from the footer is enough.
|
|
427
|
+
*
|
|
428
|
+
* 🛑 `/user/become-seller` cannot go here either: `src/proxy.ts` exempts
|
|
429
|
+
* the whole `/user` subtree from the route gate because it owns its own
|
|
430
|
+
* RBAC. Seller recruitment is withdrawn by dropping its LINKS instead —
|
|
431
|
+
* the footer allow-list and the account sidebar's Selling group.
|
|
432
|
+
*/
|
|
433
|
+
closedPaths: [
|
|
434
|
+
String(ROUTES.PUBLIC.DEVELOPER),
|
|
435
|
+
String(ROUTES.PUBLIC.FEES),
|
|
436
|
+
String(ROUTES.PUBLIC.HELP_SELLER_BADGES),
|
|
437
|
+
String(ROUTES.PUBLIC.HOW_AUCTIONS_WORK),
|
|
438
|
+
String(ROUTES.PUBLIC.HOW_EMI_WORKS),
|
|
439
|
+
String(ROUTES.PUBLIC.HOW_OFFERS_WORK),
|
|
440
|
+
String(ROUTES.PUBLIC.HOW_PAYOUTS_WORK),
|
|
441
|
+
String(ROUTES.PUBLIC.ITEM_REQUESTS),
|
|
442
|
+
String(ROUTES.PUBLIC.LOTTERIES),
|
|
443
|
+
String(ROUTES.PUBLIC.PARTS),
|
|
444
|
+
String(ROUTES.PUBLIC.PROMOTIONS),
|
|
445
|
+
String(ROUTES.PUBLIC.REPORT_ENTITY),
|
|
446
|
+
String(ROUTES.PUBLIC.SCAMS),
|
|
447
|
+
String(ROUTES.PUBLIC.SELLER_GUIDE),
|
|
448
|
+
],
|
|
394
449
|
},
|
|
395
450
|
shipping: {
|
|
396
451
|
estimatedDays: 5,
|
|
@@ -19,6 +19,9 @@ import { safeRead } from "../../../errors/safe-read.js";
|
|
|
19
19
|
import { fetchLiveStats } from "../lib/live-stats.js";
|
|
20
20
|
import { renderSection, AnnouncementBar } from "../lib/section-renderer.js";
|
|
21
21
|
import { gateCarouselSlides } from "../lib/slide-gating.js";
|
|
22
|
+
import { gateHomepageSections } from "../lib/section-gating.js";
|
|
23
|
+
import { closedSurfaceContext, isClosedHref } from "../../../_internal/shared/listing-types/route-gating.js";
|
|
24
|
+
import { getSurfaceCounts } from "../../../_internal/server/features/products/surface-counts.js";
|
|
22
25
|
import { SECTION_COPY } from "../constants/section-copy.js";
|
|
23
26
|
import { homepageSectionsRepository } from "../repository/homepage-sections.repository.js";
|
|
24
27
|
import { isListingTypeEnabled, isCategoryTypeEnabled, isBrowsePageEnabled, } from "../../../_internal/shared/listing-types/feature-flags.js";
|
|
@@ -111,7 +114,7 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
|
|
|
111
114
|
// promotional copy for a promotion that does not exist.
|
|
112
115
|
const announcementMessage = siteSettings?.announcementBar?.message?.trim() || SECTION_COPY.announcementFallback;
|
|
113
116
|
const showAnnouncement = siteSettings?.announcementBar?.enabled ?? true;
|
|
114
|
-
const [allSections, rawFaqItems] = await Promise.all([
|
|
117
|
+
const [allSections, rawFaqItems, surfaceCounts] = await Promise.all([
|
|
115
118
|
safeRead(() => homepageSectionsRepository.getEnabledSections(), {
|
|
116
119
|
route: "/", key: "homepage.enabledSections", fallback: [],
|
|
117
120
|
}),
|
|
@@ -119,7 +122,50 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
|
|
|
119
122
|
safeRead(() => getHomepageFaqsCached(), {
|
|
120
123
|
route: "/", key: "homepage.faqs", fallback: [],
|
|
121
124
|
}),
|
|
125
|
+
/*
|
|
126
|
+
* 🛑 The homepage did NOT read these until 2026-10-27, and that was the
|
|
127
|
+
* whole reason it kept advertising `/art` and `/spares` after both began
|
|
128
|
+
* answering a real 404: the flag for each was still `true` and only the
|
|
129
|
+
* measured count said empty. Every other surface on the site — nav,
|
|
130
|
+
* footer, sitemap, the edge gate — already ANDed the two.
|
|
131
|
+
*
|
|
132
|
+
* `getSurfaceCounts` swallows its own failures and returns `undefined`,
|
|
133
|
+
* which `isListingSurfaceVisible` reads as VISIBLE. So an unmeasured or
|
|
134
|
+
* unreadable count leaves the homepage as authored rather than stripping
|
|
135
|
+
* it (Root Cause #72) — which is also why it needs no `safeRead` wrapper.
|
|
136
|
+
*/
|
|
137
|
+
getSurfaceCounts(),
|
|
122
138
|
]);
|
|
139
|
+
/*
|
|
140
|
+
* One context for every link decision on this page — the carousel slides and
|
|
141
|
+
* the section configs both consult it, so they cannot disagree about which
|
|
142
|
+
* destinations are open. Built once because the prefix list is derived work
|
|
143
|
+
* and the callers are loops over dozens of links.
|
|
144
|
+
*/
|
|
145
|
+
const closedSurfaces = closedSurfaceContext(siteSettings, surfaceCounts);
|
|
146
|
+
/*
|
|
147
|
+
* 🛑 THE ANNOUNCEMENT BAR IS AUTHORED CONTENT TOO, and it is the FIRST thing
|
|
148
|
+
* on the page — so it was the worst remaining place to advertise a closed
|
|
149
|
+
* surface. Found by screenshotting the built page rather than by reading the
|
|
150
|
+
* HTML: the live bar read "… — See Events for codes" and linked `/events`,
|
|
151
|
+
* which `disabledRoutes` now closes.
|
|
152
|
+
*
|
|
153
|
+
* It survived the grep that caught everything else because the bar is
|
|
154
|
+
* CLIENT-rendered through a portal, so `/events` never appears as an `href`
|
|
155
|
+
* in the server HTML — it arrives as a prop in the RSC payload. The tell was
|
|
156
|
+
* in the browser console, not the markup: a `next/link` prefetch of
|
|
157
|
+
* `/events?_rsc=…` answering **404**.
|
|
158
|
+
*
|
|
159
|
+
* Dropping only the LINK is deliberate, and the message is left alone: the
|
|
160
|
+
* copy is the admin's, this code cannot rewrite English, and a bar that
|
|
161
|
+
* still reads "See Events for codes" with nothing to click is a smaller
|
|
162
|
+
* wrong than silently editing someone's promotional copy. The honest fix for
|
|
163
|
+
* the wording is the admin changing it — which `close-remaining-surfaces.mjs`
|
|
164
|
+
* now does for the live document.
|
|
165
|
+
*/
|
|
166
|
+
const announcementLink = isClosedHref(siteSettings?.announcementBar?.link, closedSurfaces)
|
|
167
|
+
? undefined
|
|
168
|
+
: siteSettings?.announcementBar?.link;
|
|
123
169
|
// A homepage section must not advertise a listing type the site does not
|
|
124
170
|
// offer. This gate previously read `featureFlags.{auctions,preOrders,events,
|
|
125
171
|
// blog}`; that group was deleted on 2026-08-29, and only the listing-type
|
|
@@ -132,7 +178,7 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
|
|
|
132
178
|
// no product control for "we don't run events", and there never was one that
|
|
133
179
|
// anything but this line read. Those are switched off per-document instead
|
|
134
180
|
// (`enabled: false`), which is the only lever they have.
|
|
135
|
-
const
|
|
181
|
+
const typeGatedSections = allSections.filter((section) => {
|
|
136
182
|
const type = section.type;
|
|
137
183
|
const gatedListing = HOMEPAGE_SECTION_LISTING_TYPE[type];
|
|
138
184
|
if (gatedListing && !isListingTypeEnabled(gatedListing, siteSettings))
|
|
@@ -145,6 +191,22 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
|
|
|
145
191
|
return false;
|
|
146
192
|
return true;
|
|
147
193
|
});
|
|
194
|
+
/*
|
|
195
|
+
* The FOURTH gate, and the only one that can see authored content.
|
|
196
|
+
*
|
|
197
|
+
* 🛑 All three maps above are keyed on `SectionType`, so none of them can
|
|
198
|
+
* say anything about a `custom-cards` / `promo-grid` / `banner` / `welcome`
|
|
199
|
+
* section — one generic type whose only tie to a surface is where its
|
|
200
|
+
* buttons point. Measured on the live homepage 2026-10-27, that blind spot
|
|
201
|
+
* was shipping **34 links to the closed `/categories` section** and **3 each
|
|
202
|
+
* to `/art` and `/spares`, both of which already answered a real 404**, from
|
|
203
|
+
* four sections the gates above had no opinion about.
|
|
204
|
+
*
|
|
205
|
+
* Runs AFTER the type gates, not instead of them: a section the type gate
|
|
206
|
+
* already dropped must not be walked at all, and the type gates remain the
|
|
207
|
+
* right answer for a section that IS a surface.
|
|
208
|
+
*/
|
|
209
|
+
const enabledSections = gateHomepageSections(typeGatedSections, closedSurfaces);
|
|
148
210
|
// Collect live metric requests from all enabled stats sections
|
|
149
211
|
const liveStatRequests = [];
|
|
150
212
|
for (const section of enabledSections) {
|
|
@@ -278,6 +340,6 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
|
|
|
278
340
|
* type, so a keyword scan would miss the worse of the two offenders while
|
|
279
341
|
* eating innocent ones. See `slide-gating.ts`.
|
|
280
342
|
*/
|
|
281
|
-
const carouselSlides = gateCarouselSlides(toCarouselSlides(slides),
|
|
282
|
-
return (_jsx(Main, { children: _jsxs(Div, { className: "relative", children: [showAnnouncement ? (_jsx(TopBannerPortal, { children: _jsx(AnnouncementBar, { message: announcementMessage, link:
|
|
343
|
+
const carouselSlides = gateCarouselSlides(toCarouselSlides(slides), closedSurfaces);
|
|
344
|
+
return (_jsx(Main, { children: _jsxs(Div, { className: "relative", children: [showAnnouncement ? (_jsx(TopBannerPortal, { children: _jsx(AnnouncementBar, { message: announcementMessage, link: announcementLink, onDismiss: onBannerDismiss }) })) : null, orderedSections.map((section) => renderSection(section, adSlots, newsletterFormSlot ?? null, faqItems, carouselSlides, liveStats, sectionData, brand))] }) }));
|
|
283
345
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Homepage SECTION-CONFIG gating — strip authored links that point at a closed
|
|
3
|
+
* surface, and drop whatever is left with nothing to say.
|
|
4
|
+
*
|
|
5
|
+
* ## The gap this closes
|
|
6
|
+
*
|
|
7
|
+
* `MarketplaceHomepageView` has three section gates and they are all keyed on
|
|
8
|
+
* `SectionType`: `HOMEPAGE_SECTION_LISTING_TYPE`,
|
|
9
|
+
* `HOMEPAGE_SECTION_CATEGORY_TYPE`, `HOMEPAGE_SECTION_BROWSE_PAGE`. They work,
|
|
10
|
+
* and they are enough for a section that IS a surface — the auctions rail, the
|
|
11
|
+
* categories rail, the stores rail.
|
|
12
|
+
*
|
|
13
|
+
* They cannot see a `custom-cards`, `promo-grid`, `banner`, `welcome` or
|
|
14
|
+
* `collection-cards` section, because those are one generic type holding
|
|
15
|
+
* admin-authored content whose only tie to a surface is where its buttons
|
|
16
|
+
* point. `gateCarouselSlides` is the same realisation for the `carousel` type;
|
|
17
|
+
* this is the rest of the homepage.
|
|
18
|
+
*
|
|
19
|
+
* ## 🛑 Measured on the live homepage, 2026-10-27
|
|
20
|
+
*
|
|
21
|
+
* With all four browse pages closed since 2026-10-24, and `art`/`spares`
|
|
22
|
+
* auto-hidden at count 0 (both already answering a real 404 at the edge), a
|
|
23
|
+
* production fetch of `/` carried:
|
|
24
|
+
*
|
|
25
|
+
* | links | to | state |
|
|
26
|
+
* |---|---|---|
|
|
27
|
+
* | **34** | `/categories` + `/categories/{slug}` | section closed |
|
|
28
|
+
* | 3 | `/art` | **404** |
|
|
29
|
+
* | 3 | `/spares` | **404** |
|
|
30
|
+
*
|
|
31
|
+
* Three ungated sections produced all of it: `section-welcome-hero`
|
|
32
|
+
* (`secondaryCtaLink`), `section-systems-matrix` (35 category buttons across 4
|
|
33
|
+
* cards) and `section-generation-promos` (4 category banners). Plus
|
|
34
|
+
* `section-shop-extras`, whose three cards pointed at `/art`, `/spares` and
|
|
35
|
+
* `/bundles`.
|
|
36
|
+
*
|
|
37
|
+
* A rail that advertises a closed door is worse than an absent rail: the
|
|
38
|
+
* visitor spends a click to find out, and a crawler spends budget.
|
|
39
|
+
*
|
|
40
|
+
* ## The three rules, in order
|
|
41
|
+
*
|
|
42
|
+
* 1. **A closed link is removed.** Button, banner, CTA — whatever carries it.
|
|
43
|
+
* 2. **A container whose links were ALL closed is removed.** A card or banner
|
|
44
|
+
* with its CTAs stripped is a picture with no destination; it reads as a
|
|
45
|
+
* broken tile, not as a tidied one.
|
|
46
|
+
* 3. **A section left with no linked content is removed.** This is rule 2 one
|
|
47
|
+
* level up, and it is what turns the systems matrix (every one of its 35
|
|
48
|
+
* buttons closed) from "four empty cards" into "not rendered".
|
|
49
|
+
*
|
|
50
|
+
* 🛑 Rule 3 applies ONLY to sections whose content IS the links. A `welcome`
|
|
51
|
+
* hero that loses its secondary CTA keeps its heading, its copy and its
|
|
52
|
+
* primary CTA to `/products`, and must still render — dropping it would blank
|
|
53
|
+
* the top of the homepage because one of its two buttons was withdrawn. That
|
|
54
|
+
* is why `LINK_BEARING_SECTIONS` is an explicit allow-list keyed on
|
|
55
|
+
* `SectionType` rather than "any section with no links left".
|
|
56
|
+
*
|
|
57
|
+
* ## Fails OPEN throughout
|
|
58
|
+
*
|
|
59
|
+
* Every decision runs through `isClosedHref`, which classifies nothing when
|
|
60
|
+
* the settings document is missing. A transient Firestore error therefore
|
|
61
|
+
* leaves the homepage exactly as authored rather than stripping it to the
|
|
62
|
+
* studs — the same direction as every other gate on this page (Root Cause
|
|
63
|
+
* #72).
|
|
64
|
+
*
|
|
65
|
+
* @tag domain:homepage
|
|
66
|
+
* @tag layer:shared
|
|
67
|
+
* @tag access:isomorphic
|
|
68
|
+
* @tag sideEffects:none
|
|
69
|
+
*/
|
|
70
|
+
import { type ClosedSurfaceContext } from "../../../_internal/shared/listing-types/route-gating";
|
|
71
|
+
import type { HomepageSectionDocument } from "../schemas/firestore";
|
|
72
|
+
/**
|
|
73
|
+
* Strip closed destinations from one section, or return `null` to drop it.
|
|
74
|
+
*
|
|
75
|
+
* Exported for the audit and for direct use; `gateHomepageSections` is the
|
|
76
|
+
* normal entry point.
|
|
77
|
+
*/
|
|
78
|
+
export declare function gateHomepageSection<T extends HomepageSectionDocument>(section: T, ctx: ClosedSurfaceContext): T | null;
|
|
79
|
+
/**
|
|
80
|
+
* Apply `gateHomepageSection` across an ordered section list.
|
|
81
|
+
*
|
|
82
|
+
* Returns a new array; input is not mutated.
|
|
83
|
+
*/
|
|
84
|
+
export declare function gateHomepageSections<T extends HomepageSectionDocument>(sections: readonly T[], ctx: ClosedSurfaceContext): T[];
|