@mohasinac/appkit 4.67.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.
Files changed (27) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/seo/sitemap.js +27 -13
  3. package/dist/_internal/server/features/site-settings/adapters.js +27 -0
  4. package/dist/_internal/server/functions/firestore.d.ts +18 -1
  5. package/dist/_internal/server/functions/firestore.js +25 -1
  6. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  7. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  8. package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.d.ts +53 -0
  9. package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.js +63 -0
  10. package/dist/_internal/shared/features/site-settings/types.d.ts +46 -0
  11. package/dist/_internal/shared/listing-types/feature-flags.d.ts +12 -0
  12. package/dist/_internal/shared/listing-types/feature-flags.js +14 -0
  13. package/dist/_internal/shared/listing-types/hidden-surfaces.d.ts +112 -0
  14. package/dist/_internal/shared/listing-types/hidden-surfaces.js +170 -0
  15. package/dist/_internal/shared/listing-types/route-gating.d.ts +67 -25
  16. package/dist/_internal/shared/listing-types/route-gating.js +71 -27
  17. package/dist/features/about/components/HelpPageView.js +19 -1
  18. package/dist/features/admin/schemas/firestore.d.ts +23 -0
  19. package/dist/features/admin/schemas/firestore.js +63 -8
  20. package/dist/features/homepage/components/MarketplaceHomepageView.js +66 -4
  21. package/dist/features/homepage/lib/section-gating.d.ts +84 -0
  22. package/dist/features/homepage/lib/section-gating.js +224 -0
  23. package/dist/features/homepage/lib/slide-gating.d.ts +14 -14
  24. package/dist/features/homepage/lib/slide-gating.js +21 -15
  25. package/dist/features/products/components/CardStoreLine.js +23 -0
  26. package/dist/features/products/components/ProductDetailPageView.js +30 -3
  27. package/package.json +1 -1
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Which public routes must not be served, given the admin flags and the
3
+ * measured counts.
4
+ *
5
+ * ## Why this is one module and not copies at each reader
6
+ *
7
+ * Two places need these answers and they must never disagree:
8
+ *
9
+ * · `buildSitemap` — decides which URLs are advertised.
10
+ * · `toPublicSiteSettings` → `src/proxy.ts` — decides which 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 — and the disagreement showed up
16
+ * anyway on the one axis only one of them read (see `closedSurfacePrefixes`).
17
+ *
18
+ * ## 🛑 TWO FUNCTIONS, because the MATCH RULE differs — and each rule is
19
+ * load-bearing in the opposite direction
20
+ *
21
+ * | | rule | why the other rule would break it |
22
+ * |---|---|---|
23
+ * | `hiddenSurfaceRoutes` — listing-type browse pages | **exact path** | `art`, `stickers`, `spares` and `standard` all detail at `/products/{slug}`, so a prefix rule would 404 the whole standard catalogue the day `spares` emptied |
24
+ * | `closedSurfacePrefixes` — browse sections, bundles, `disabledRoutes` | **prefix** | `/categories/{slug}` is owned by `categories` alone, and exact-only matching left all 4,698 of them serving a real 200 while `/categories` said 404 |
25
+ *
26
+ * Do not merge them. Picking either rule for both is a live defect one way or
27
+ * the other, and both directions have now been observed in production.
28
+ *
29
+ * ## 🛑 A SHARED route survives while ANY of its types is visible
30
+ *
31
+ * `/art` spans `art` and `stickers`; `/products` spans `standard` plus several
32
+ * others. Without `hiddenSurfaceRoutes`' second pass, hiding `stickers` alone
33
+ * would close a page `art` still fills — and hiding `spares` alone would close
34
+ * `/products`.
35
+ *
36
+ * @tag domain:products
37
+ * @tag layer:shared
38
+ * @tag access:isomorphic
39
+ * @tag sideEffects:none
40
+ */
41
+ import { ALL_BROWSE_PAGES, ALL_LISTING_TYPES, browsePagePrefixes, isBrowsePageEnabled, isCategoryTypeEnabled, isListingSurfaceVisible, } from "./feature-flags.js";
42
+ import { pluginFor } from "./_registry.js";
43
+ import { ROUTES } from "../../../next/routing/route-map.js";
44
+ export function hiddenSurfaceRoutes(settings, counts) {
45
+ const hidden = new Set();
46
+ const routeOf = (type) => String(pluginFor(type).browseRoute ?? "");
47
+ for (const type of ALL_LISTING_TYPES) {
48
+ if (isListingSurfaceVisible(type, settings, counts))
49
+ continue;
50
+ const route = routeOf(type);
51
+ if (route)
52
+ hidden.add(route);
53
+ }
54
+ /*
55
+ * Second pass, deliberately not folded into the first: a route is only
56
+ * hidden once EVERY type that browses there is hidden, and the first pass
57
+ * cannot know that until it has seen them all.
58
+ */
59
+ for (const type of ALL_LISTING_TYPES) {
60
+ if (isListingSurfaceVisible(type, settings, counts))
61
+ hidden.delete(routeOf(type));
62
+ }
63
+ return [...hidden];
64
+ }
65
+ /**
66
+ * Closed routes that must take their DESCENDANTS with them.
67
+ *
68
+ * ## Why this is a second function and not more entries in the first
69
+ *
70
+ * `hiddenSurfaceRoutes` above is exact-path-only, and its docstring explains
71
+ * why it has to be: `art`, `stickers`, `spares` and `standard` all detail at
72
+ * `/products/{slug}`, so a prefix rule there would 404 the whole standard
73
+ * catalogue the day `spares` emptied.
74
+ *
75
+ * A closed BROWSE PAGE has the opposite property. `/categories/{slug}` belongs
76
+ * to `categories` and to nothing else — no open surface details underneath it
77
+ * — so there is no shared-route hazard, and exact-only matching leaves the
78
+ * interesting half of the section served.
79
+ *
80
+ * 🛑 THIS WAS MEASURED OPEN IN PRODUCTION, 2026-10-27. All four browse pages
81
+ * had been `false` in `listings.browsePages` since 2026-10-24 and:
82
+ *
83
+ * ```
84
+ * /categories 200 body = "404 — Page not found"
85
+ * /categories/category-beyblade-x 200 a real, fully-rendered category page
86
+ * /stores/store-beyblade-arena 200 a real, fully-rendered store page
87
+ * ```
88
+ *
89
+ * The index was a SOFT 404 (`guardBrowsePage` throws `notFound()` after the
90
+ * shell has streamed, which cannot set the status — see the site-wide soft-404
91
+ * note) and the 4,698 detail pages were never guarded at all. So "the section
92
+ * is closed" meant "the front door says 404 while every room stays open and
93
+ * indexable".
94
+ *
95
+ * ## `disabledRoutes` is folded in here, not left to the proxy
96
+ *
97
+ * The proxy already prefix-matches `disabledRoutes`, so including it changes
98
+ * nothing at the edge. It is here because the SITEMAP needs the same list and
99
+ * did not have it: measured the same day, the sitemap advertised 17 `/blog`
100
+ * URLs and one `/reviews` URL, every one of which the edge already 404s. That
101
+ * is a crawl error on a page we asked Google to fetch — Root Cause #81's
102
+ * host/sitemap disagreement in miniature, and the precise reason
103
+ * `hiddenSurfaceRoutes` was extracted into one definition in the first place.
104
+ *
105
+ * One list, two readers, no way for them to disagree:
106
+ * · `toPublicSiteSettings` → `src/proxy.ts` — which paths 404.
107
+ * · `buildSitemap` — which paths are advertised.
108
+ *
109
+ * Fails OPEN on a missing settings document: every `isBrowsePageEnabled` call
110
+ * returns `true` and the list is empty, so a transient Firestore error cannot
111
+ * withdraw the catalogue (Root Cause #72).
112
+ */
113
+ export function closedSurfacePrefixes(settings) {
114
+ const closed = new Set();
115
+ for (const page of ALL_BROWSE_PAGES) {
116
+ if (isBrowsePageEnabled(page, settings))
117
+ continue;
118
+ for (const prefix of browsePagePrefixes(page)) {
119
+ if (prefix)
120
+ closed.add(prefix);
121
+ }
122
+ }
123
+ /*
124
+ * Bundles — a CATEGORY type, not a listing type and not a browse page, so it
125
+ * reaches neither loop above.
126
+ *
127
+ * `listings.categoryTypes.bundle` is the control that already exists for it,
128
+ * and `HOMEPAGE_SECTION_CATEGORY_TYPE` in `MarketplaceHomepageView` already
129
+ * drops the bundles rail from it — so until this line the flag hid the rail
130
+ * while `/bundles` and every `/bundles/{slug}` went on serving. That is the
131
+ * same half-closed door the browse pages had.
132
+ *
133
+ * Prefix rather than exact for the same reason as a browse page: `/bundles`
134
+ * is owned by bundles alone, with no open surface detailing underneath it.
135
+ */
136
+ if (!isCategoryTypeEnabled("bundle", settings)) {
137
+ closed.add(String(ROUTES.PUBLIC.BUNDLES));
138
+ }
139
+ /*
140
+ * Admin-set feature withdrawals — `/seller-guide`, `/fees`, `/scams`, …
141
+ *
142
+ * 🛑 A SEPARATE FIELD from `disabledRoutes` below, not a duplicate of it.
143
+ * That array is DERIVED from `navConfig` and fully recomputed on every nav
144
+ * toggle, so a non-nav path appended there is wiped by the next admin save.
145
+ * Anything footer-only — which is most of a marketplace's explainer
146
+ * surfaces — has no nav item and can only live here.
147
+ */
148
+ for (const path of settings?.listings?.closedPaths ?? []) {
149
+ if (typeof path === "string" && path.startsWith("/"))
150
+ closed.add(path);
151
+ }
152
+ for (const route of settings?.disabledRoutes ?? []) {
153
+ if (typeof route === "string" && route.startsWith("/"))
154
+ closed.add(route);
155
+ }
156
+ return [...closed];
157
+ }
158
+ /**
159
+ * Does `path` sit on or under one of `prefixes`?
160
+ *
161
+ * 🛑 A `/`-delimited boundary, never a bare `startsWith`. `/store` is the
162
+ * SELLER DASHBOARD and `/stores` is the public directory, so a substring test
163
+ * would 404 a signed-in seller's own dashboard the moment an admin closed the
164
+ * public storefronts — a different surface, a different audience, one
165
+ * character apart. Same rule, same reason, as `browsePageForPath`.
166
+ */
167
+ export function isUnderClosedPrefix(path, prefixes) {
168
+ const normalised = path.replace(/\/+$/, "") || "/";
169
+ return prefixes.some((p) => normalised === p || normalised.startsWith(`${p}/`));
170
+ }
@@ -17,40 +17,82 @@
17
17
  * type-keyed gate could see them — the only thing that identifies them is
18
18
  * where they point.
19
19
  *
20
- * ## The rule, and the one case that makes it non-obvious
20
+ * ## Where the rules are written down
21
21
  *
22
- * A href is disabled when EVERY listing type that claims its route is
23
- * disabled. Not "any" — `art` and `stickers` share `/art`, so a site that
24
- * sells art but not stickers must keep `/art` reachable. Treating one disabled
25
- * claimant as enough would silently delete a working destination.
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
- * ## What it deliberately does NOT match
28
- *
29
- * Editorial pages that merely have a type's name in the path — `/help/auctions`,
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
  */
33
+ import { type BrowsePageKey, type SurfaceCountSnapshot } from "./feature-flags";
39
34
  import type { ListingType } from "../../../features/products/types/index";
40
35
  interface ListingFlagSnapshot {
41
36
  listings?: {
42
37
  listingTypes?: Partial<Record<ListingType, boolean>>;
38
+ browsePages?: Partial<Record<BrowsePageKey, boolean>>;
43
39
  };
40
+ disabledRoutes?: string[];
44
41
  }
45
42
  /**
46
- * `true` when `href` points at a listing type's browse page (or any page
47
- * beneath it) and every type that browses there is disabled.
48
- *
49
- * Fails OPEN on anything it cannot classify: an unknown path, an external URL
50
- * or a missing settings document all return `false`. A settings read that
51
- * errored must not strip working destinations out of the homepage — the same
52
- * reasoning as the suggestions endpoint's TTL cache and `src/proxy.ts`'s
53
- * disabled-routes gate.
43
+ * Everything needed to answer "is this destination closed", resolved once.
44
+ *
45
+ * 🛑 A CONTEXT RATHER THAN THREE LOOSE ARGUMENTS, because the prefix list is
46
+ * derived work and the callers are loops. The live homepage carries **34 links
47
+ * to `/categories` alone** (measured 2026-10-27); rebuilding the closed-prefix
48
+ * array inside the predicate would rebuild it 34 times for one render.
49
+ */
50
+ export interface ClosedSurfaceContext {
51
+ settings: ListingFlagSnapshot | null | undefined;
52
+ counts: SurfaceCountSnapshot | null | undefined;
53
+ /** Closed browse sections + `disabledRoutes`, pre-derived. */
54
+ prefixes: string[];
55
+ }
56
+ export declare function closedSurfaceContext(settings: ListingFlagSnapshot | null | undefined, counts?: SurfaceCountSnapshot | null): ClosedSurfaceContext;
57
+ /**
58
+ * `true` when `href` leads somewhere this site has closed.
59
+ *
60
+ * ## THREE axes, because authored content can point at any of them
61
+ *
62
+ * This replaced `isDisabledListingHref`, which read `listings.listingTypes`
63
+ * and nothing else — and the two axes it could not see were the two doing
64
+ * visible damage. Measured on the live homepage, 2026-10-27:
65
+ *
66
+ * · **counts.** `art` and `spares` had flag `true` and measured count `0`,
67
+ * so they were auto-hidden everywhere — `/art` and `/spares` both answered
68
+ * a real **404** at the edge — while the homepage went on shipping 3 links
69
+ * to each from the "Shop extras" cards. `isListingTypeEnabled` reads the
70
+ * flag only, so it called both of them enabled. `isListingSurfaceVisible`
71
+ * ANDs the flag with the count, which is the rule every other surface on
72
+ * the site already uses.
73
+ * · **browse sections.** All four had been `false` since 2026-10-24, and the
74
+ * homepage carried **34** `/categories` links across the welcome hero's
75
+ * secondary CTA, the 35-button systems matrix and the 4-banner generation
76
+ * promo grid.
77
+ *
78
+ * ## Deliberately link-based, never keyword-based
79
+ *
80
+ * Unchanged from the original, and still the point: nothing here reads copy.
81
+ * "Win it at your price" names no type, so a word search would miss it, and it
82
+ * would also eat the authenticity slide for saying "bid" in passing. A
83
+ * destination is checkable; prose is not.
84
+ *
85
+ * ## What it still does NOT match
86
+ *
87
+ * Editorial pages that merely carry a name in the path — `/help/auctions`,
88
+ * `/how-auctions-work`, `/seller-guide/prize-draws`. None starts with
89
+ * `/auctions`, so the `/`-delimited boundary excludes them for free, and that
90
+ * is correct: an explainer about auctions is honest reading when auctions are
91
+ * off.
92
+ *
93
+ * Fails OPEN on anything it cannot classify — an unknown path, an external
94
+ * URL, a missing settings document. A settings read that errored must not
95
+ * strip working destinations off the homepage (Root Cause #72).
54
96
  */
55
- export declare function isDisabledListingHref(href: string | undefined | null, settings: ListingFlagSnapshot | null | undefined): boolean;
97
+ export declare function isClosedHref(href: string | undefined | null, ctx: ClosedSurfaceContext): boolean;
56
98
  export {};
@@ -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
- * ## The rule, and the one case that makes it non-obvious
20
+ * ## Where the rules are written down
21
21
  *
22
- * A href is disabled when EVERY listing type that claims its route is
23
- * disabled. Not "any" — `art` and `stickers` share `/art`, so a site that
24
- * sells art but not stickers must keep `/art` reachable. Treating one disabled
25
- * claimant as enough would silently delete a working destination.
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
- * ## What it deliberately does NOT match
28
- *
29
- * Editorial pages that merely have a type's name in the path — `/help/auctions`,
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, isListingTypeEnabled } from "./feature-flags.js";
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` points at a listing type's browse page (or any page
93
- * beneath it) and every type that browses there is disabled.
94
- *
95
- * Fails OPEN on anything it cannot classify: an unknown path, an external URL
96
- * or a missing settings document all return `false`. A settings read that
97
- * errored must not strip working destinations out of the homepage — the same
98
- * reasoning as the suggestions endpoint's TTL cache and `src/proxy.ts`'s
99
- * disabled-routes gate.
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 isDisabledListingHref(href, settings) {
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
- return types.every((type) => !isListingTypeEnabled(type, settings));
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
- giftWrapFeeEnabled: true,
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: true,
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
- art: true,
357
- stickers: true,
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 is what hides the
366
- // homepage's featured-bundles rail and the bundle tabs on category and
367
- // brand pages. Same reasoning as the five listing types above.
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,