@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
@@ -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 enabledSections = allSections.filter((section) => {
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), siteSettings);
282
- return (_jsx(Main, { children: _jsxs(Div, { className: "relative", children: [showAnnouncement ? (_jsx(TopBannerPortal, { children: _jsx(AnnouncementBar, { message: announcementMessage, link: siteSettings?.announcementBar?.link, onDismiss: onBannerDismiss }) })) : null, orderedSections.map((section) => renderSection(section, adSlots, newsletterFormSlot ?? null, faqItems, carouselSlides, liveStats, sectionData, brand))] }) }));
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[];
@@ -0,0 +1,224 @@
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 { isClosedHref, } from "../../../_internal/shared/listing-types/route-gating.js";
71
+ /**
72
+ * Section types whose entire substance is a set of linked tiles, and which are
73
+ * therefore meaningless once every link is gone.
74
+ *
75
+ * ๐Ÿ›‘ An explicit allow-list, not a heuristic. `welcome`, `banner` and
76
+ * `newsletter` all carry links AND standalone copy โ€” a `welcome` hero stripped
77
+ * of its secondary CTA still has an h1, a description and a primary CTA, so
78
+ * "no links left" must not drop it. Getting this backwards blanks the top of
79
+ * the homepage.
80
+ *
81
+ * Keyed on `SectionType` so an unlisted type is never dropped by rule 3,
82
+ * which is the safe default: a section that survives with a missing button is
83
+ * a visible prompt to go and fix the content, while a section wrongly removed
84
+ * is invisible.
85
+ */
86
+ const LINK_BEARING_SECTIONS = new Set([
87
+ "custom-cards",
88
+ "promo-grid",
89
+ "collection-cards",
90
+ ]);
91
+ /**
92
+ * Config keys that hold a single destination, paired with the sibling key that
93
+ * labels it.
94
+ *
95
+ * Both are cleared together: a `secondaryCtaText` with no `secondaryCtaLink`
96
+ * renders a button that goes nowhere, which is strictly worse than no button.
97
+ * `banner`/`features` CTAs use `link`, `welcome`/`how-it-works` use
98
+ * `ctaLink` โ€” all three spellings are live in the seeded configs.
99
+ */
100
+ const SCALAR_CTA_KEYS = [
101
+ ["ctaLink", "ctaText"],
102
+ ["secondaryCtaLink", "secondaryCtaText"],
103
+ ["learnMoreLink", "learnMoreLabel"],
104
+ ["ctaUrl", "ctaLabel"],
105
+ ];
106
+ /** Keys whose value is an array of `{ href | link | ctaUrl }` objects. */
107
+ const LINK_ARRAY_KEYS = ["buttons", "banners", "cards", "collections", "links", "items"];
108
+ function isDict(v) {
109
+ return typeof v === "object" && v !== null && !Array.isArray(v);
110
+ }
111
+ /** Every destination spelling an authored object may carry. */
112
+ function destinationOf(node) {
113
+ for (const key of ["href", "link", "url", "ctaUrl", "ctaLink"]) {
114
+ const v = node[key];
115
+ if (typeof v === "string" && v.length > 0)
116
+ return v;
117
+ }
118
+ return undefined;
119
+ }
120
+ /**
121
+ * Recursively prune closed destinations out of one config value.
122
+ *
123
+ * Returns `{ value, hadLinks, keptLinks }` so a caller can tell "this subtree
124
+ * never advertised anything" from "this subtree advertised only closed
125
+ * things" โ€” the distinction rules 2 and 3 both turn on, and the one a bare
126
+ * emptiness test gets wrong. `[].every()` is `true`, so an image-only card
127
+ * would otherwise be dropped for having no surviving links when it never had
128
+ * any (the same trap `isCardDisabled` documents in `slide-gating`).
129
+ */
130
+ function prune(node, ctx) {
131
+ if (Array.isArray(node)) {
132
+ let hadLinks = 0;
133
+ let keptLinks = 0;
134
+ const out = [];
135
+ for (const item of node) {
136
+ const r = prune(item, ctx);
137
+ hadLinks += r.hadLinks;
138
+ // Rule 2: a container that advertised only closed destinations goes.
139
+ if (r.hadLinks > 0 && r.keptLinks === 0)
140
+ continue;
141
+ keptLinks += r.keptLinks;
142
+ out.push(r.value);
143
+ }
144
+ return { value: out, hadLinks, keptLinks };
145
+ }
146
+ if (!isDict(node))
147
+ return { value: node, hadLinks: 0, keptLinks: 0 };
148
+ const out = { ...node };
149
+ let hadLinks = 0;
150
+ let keptLinks = 0;
151
+ // This object's own destination, if it has one.
152
+ const own = destinationOf(node);
153
+ if (own !== undefined) {
154
+ hadLinks += 1;
155
+ if (isClosedHref(own, ctx)) {
156
+ for (const key of ["href", "link", "url", "ctaUrl", "ctaLink"])
157
+ delete out[key];
158
+ }
159
+ else {
160
+ keptLinks += 1;
161
+ }
162
+ }
163
+ // Scalar CTA pairs โ€” cleared with their labels.
164
+ for (const [linkKey, labelKey] of SCALAR_CTA_KEYS) {
165
+ const v = node[linkKey];
166
+ if (typeof v !== "string" || v.length === 0)
167
+ continue;
168
+ // Already counted above when it doubled as this object's own destination.
169
+ const alreadyCounted = own === v;
170
+ if (!alreadyCounted)
171
+ hadLinks += 1;
172
+ if (isClosedHref(v, ctx)) {
173
+ delete out[linkKey];
174
+ delete out[labelKey];
175
+ }
176
+ else if (!alreadyCounted) {
177
+ keptLinks += 1;
178
+ }
179
+ }
180
+ // Nested link-bearing arrays.
181
+ for (const key of LINK_ARRAY_KEYS) {
182
+ if (!Array.isArray(node[key]))
183
+ continue;
184
+ const r = prune(node[key], ctx);
185
+ out[key] = r.value;
186
+ hadLinks += r.hadLinks;
187
+ keptLinks += r.keptLinks;
188
+ }
189
+ return { value: out, hadLinks, keptLinks };
190
+ }
191
+ /**
192
+ * Strip closed destinations from one section, or return `null` to drop it.
193
+ *
194
+ * Exported for the audit and for direct use; `gateHomepageSections` is the
195
+ * normal entry point.
196
+ */
197
+ export function gateHomepageSection(section, ctx) {
198
+ const { value, hadLinks, keptLinks } = prune(section.config, ctx);
199
+ // Rule 3: a section whose whole substance is links, left with none.
200
+ if (LINK_BEARING_SECTIONS.has(section.type) &&
201
+ hadLinks > 0 &&
202
+ keptLinks === 0) {
203
+ return null;
204
+ }
205
+ // Nothing was pruned โ€” return the original object so React sees a stable
206
+ // reference and the common case allocates nothing.
207
+ if (hadLinks === keptLinks)
208
+ return section;
209
+ return { ...section, config: value };
210
+ }
211
+ /**
212
+ * Apply `gateHomepageSection` across an ordered section list.
213
+ *
214
+ * Returns a new array; input is not mutated.
215
+ */
216
+ export function gateHomepageSections(sections, ctx) {
217
+ const kept = [];
218
+ for (const section of sections) {
219
+ const gated = gateHomepageSection(section, ctx);
220
+ if (gated !== null)
221
+ kept.push(gated);
222
+ }
223
+ return kept;
224
+ }
@@ -1,6 +1,12 @@
1
1
  /**
2
- * Carousel gating โ€” strip slides and cards that advertise a disabled listing
3
- * type.
2
+ * Carousel gating โ€” strip slides and cards that advertise a CLOSED surface.
3
+ *
4
+ * ๐Ÿ›‘ "closed surface", not "disabled listing type". This read the listing-type
5
+ * flags alone until 2026-10-27, and the two axes it could not see were the two
6
+ * doing visible damage: a type auto-hidden for being empty (`/art`, `/spares`
7
+ * โ€” both answering a real 404 while the homepage linked to them), and a closed
8
+ * browse section (`/categories`, linked 34 times from the live homepage).
9
+ * `isClosedHref` owns all three axes now; see its docstring.
4
10
  *
5
11
  * ## The gap this closes
6
12
  *
@@ -33,20 +39,14 @@
33
39
  * this filter to tidy up after it โ€” this is the safety net for content an
34
40
  * admin authors later, where the gap is a visible prompt to go and fix it.
35
41
  */
42
+ import { type ClosedSurfaceContext } from "../../../_internal/shared/listing-types/route-gating";
36
43
  import type { CarouselSlide } from "../types/index";
37
- import type { ListingType } from "../../products/types/index";
38
- interface ListingFlagSnapshot {
39
- listings?: {
40
- listingTypes?: Partial<Record<ListingType, boolean>>;
41
- };
42
- }
43
44
  /**
44
- * Filter a slide list for the current listing-type flags.
45
+ * Filter a slide list against the surfaces this site currently has open.
45
46
  *
46
47
  * Returns a new array; input is not mutated. Fails OPEN โ€” with no settings
47
- * document every `isListingTypeEnabled` call returns `true` and nothing is
48
- * removed, which is the right failure for a read that errored (Root Cause
49
- * #72: an unknown must never be read as "zero").
48
+ * document `isClosedHref` classifies nothing and nothing is removed, which is
49
+ * the right failure for a read that errored (Root Cause #72: an unknown must
50
+ * never be read as "zero").
50
51
  */
51
- export declare function gateCarouselSlides<T extends CarouselSlide>(slides: readonly T[], settings: ListingFlagSnapshot | null | undefined): T[];
52
- export {};
52
+ export declare function gateCarouselSlides<T extends CarouselSlide>(slides: readonly T[], ctx: ClosedSurfaceContext): T[];
@@ -1,6 +1,12 @@
1
1
  /**
2
- * Carousel gating โ€” strip slides and cards that advertise a disabled listing
3
- * type.
2
+ * Carousel gating โ€” strip slides and cards that advertise a CLOSED surface.
3
+ *
4
+ * ๐Ÿ›‘ "closed surface", not "disabled listing type". This read the listing-type
5
+ * flags alone until 2026-10-27, and the two axes it could not see were the two
6
+ * doing visible damage: a type auto-hidden for being empty (`/art`, `/spares`
7
+ * โ€” both answering a real 404 while the homepage linked to them), and a closed
8
+ * browse section (`/categories`, linked 34 times from the live homepage).
9
+ * `isClosedHref` owns all three axes now; see its docstring.
4
10
  *
5
11
  * ## The gap this closes
6
12
  *
@@ -33,7 +39,7 @@
33
39
  * this filter to tidy up after it โ€” this is the safety net for content an
34
40
  * admin authors later, where the gap is a visible prompt to go and fix it.
35
41
  */
36
- import { isDisabledListingHref } from "../../../_internal/shared/listing-types/route-gating.js";
42
+ import { isClosedHref, } from "../../../_internal/shared/listing-types/route-gating.js";
37
43
  /**
38
44
  * Every destination a card offers. A card with no buttons is still clickable
39
45
  * through nothing โ€” it has no link of its own โ€” so it is never gated.
@@ -50,11 +56,11 @@ function cardHrefs(card) {
50
56
  * That is why the length check is separate rather than folded in โ€” an
51
57
  * image-only card advertises no type and must survive.
52
58
  */
53
- function isCardDisabled(card, settings) {
59
+ function isCardDisabled(card, ctx) {
54
60
  const hrefs = cardHrefs(card);
55
61
  if (hrefs.length === 0)
56
62
  return false;
57
- return hrefs.every((href) => isDisabledListingHref(href, settings));
63
+ return hrefs.every((href) => isClosedHref(href, ctx));
58
64
  }
59
65
  /**
60
66
  * A slide is dropped when the thing it is ABOUT is disabled โ€” its overlay CTA
@@ -64,27 +70,27 @@ function isCardDisabled(card, settings) {
64
70
  * slide carries `overlay.button.link` AND `link.url`, while a slide can
65
71
  * legitimately have only one.
66
72
  */
67
- function isSlideDisabled(slide, settings) {
68
- if (isDisabledListingHref(slide.overlay?.button?.link, settings))
73
+ function isSlideDisabled(slide, ctx) {
74
+ if (isClosedHref(slide.overlay?.button?.link, ctx))
69
75
  return true;
70
- if (isDisabledListingHref(slide.link?.url, settings))
76
+ if (isClosedHref(slide.link?.url, ctx))
71
77
  return true;
72
78
  return false;
73
79
  }
74
80
  /**
75
- * Filter a slide list for the current listing-type flags.
81
+ * Filter a slide list against the surfaces this site currently has open.
76
82
  *
77
83
  * Returns a new array; input is not mutated. Fails OPEN โ€” with no settings
78
- * document every `isListingTypeEnabled` call returns `true` and nothing is
79
- * removed, which is the right failure for a read that errored (Root Cause
80
- * #72: an unknown must never be read as "zero").
84
+ * document `isClosedHref` classifies nothing and nothing is removed, which is
85
+ * the right failure for a read that errored (Root Cause #72: an unknown must
86
+ * never be read as "zero").
81
87
  */
82
- export function gateCarouselSlides(slides, settings) {
88
+ export function gateCarouselSlides(slides, ctx) {
83
89
  const kept = [];
84
90
  for (const slide of slides) {
85
- if (isSlideDisabled(slide, settings))
91
+ if (isSlideDisabled(slide, ctx))
86
92
  continue;
87
- const cards = (slide.cards ?? []).filter((card) => !isCardDisabled(card, settings));
93
+ const cards = (slide.cards ?? []).filter((card) => !isCardDisabled(card, ctx));
88
94
  const hadCards = (slide.cards ?? []).length > 0;
89
95
  // A cards-slide that lost ALL its cards renders as a bare background with
90
96
  // no copy and no call to action โ€” the exact "renders blank" failure the
@@ -2,6 +2,7 @@
2
2
  import { jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Text } from "../../../ui/components/Typography.js";
4
4
  import { safeDisplayName } from "../../../security/pii-redact.js";
5
+ import { useBrowsePageFlags } from "../../../react/hooks/useBrowsePageFlags.js";
5
6
  /**
6
7
  * `store-beyblade-arena` โ†’ `Beyblade Arena`.
7
8
  *
@@ -24,6 +25,28 @@ function humanizeStoreSlug(slug) {
24
25
  .join(" ");
25
26
  }
26
27
  export function CardStoreLine({ storeName, storeId, className }) {
28
+ /*
29
+ * ๐Ÿ›‘ A SELLER BYLINE IS MARKETPLACE CHROME โ€” silent on a single-vendor shop.
30
+ *
31
+ * "by LetItRip Official" repeated under every card tells a buyer nothing
32
+ * when there is only one seller: it is the same six words on every tile,
33
+ * costing a line of vertical space on all six card variants and implying a
34
+ * choice that does not exist.
35
+ *
36
+ * Keyed on whether the STORE DIRECTORY is open, the same question the
37
+ * product page's "Sold by" block asks, so the two cannot disagree. This
38
+ * hook is the client-side reader of that flag (a card is `"use client"`, so
39
+ * it cannot take the server's settings read).
40
+ *
41
+ * ๐Ÿ›‘ Fails OPEN while settings load โ€” `useBrowsePageFlags` defaults every
42
+ * section to open, so a cold paint shows the byline and then removes it
43
+ * rather than flashing it in. That is the right direction for a marketplace
44
+ * that still has one; the alternative hides a real seller on every cold
45
+ * load. Root Cause #72.
46
+ */
47
+ const browsePages = useBrowsePageFlags();
48
+ if (!browsePages.isEnabled("stores"))
49
+ return null;
27
50
  if (!storeId && !storeName)
28
51
  return null;
29
52
  // `safeDisplayName` also guards the encrypted-value case: a PII-ciphertext
@@ -63,6 +63,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
63
63
  import { getSiteSettingsGlobal } from "../../admin/utils/getSiteSettingsGlobal.js";
64
64
  import { safeRead } from "../../../errors/safe-read.js";
65
65
  import { canMakeOffer } from "../../../_internal/shared/listing-types/capabilities.js";
66
+ import { closedSurfaceContext, isClosedHref, } from "../../../_internal/shared/listing-types/route-gating.js";
66
67
  import { MessageSellerButton } from "../../messages/components/MessageSellerButton.js";
67
68
  import { normalizeListingType } from "../utils/listing-type.js";
68
69
  import { DEFAULT_MIN_OFFER_PERCENT, resolveOfferBounds, } from "../../../_internal/shared/features/offers/config.js";
@@ -348,6 +349,32 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
348
349
  : Promise.resolve(null),
349
350
  ]);
350
351
  const { relatedItems, relatedByBrand, relatedByTags, relatedByStore } = related;
352
+ /*
353
+ * ๐Ÿ›‘ SELLER IDENTITY IS MARKETPLACE CHROME, and it is wrong on a
354
+ * single-vendor storefront.
355
+ *
356
+ * "Sold by LetItRip Official", a "Visit Store โ†’" button and a "Message
357
+ * seller" control only mean something when there is more than one seller to
358
+ * choose between. With `listings.browsePages.stores` closed there is one
359
+ * vendor, the store page 404s, and the block is a link into a shut door
360
+ * sitting beside the Buy button.
361
+ *
362
+ * Keyed on whether the STORE PAGE is reachable rather than on a new flag of
363
+ * its own: that is the same question, already answered, and a second switch
364
+ * is how two controls come to disagree (Root Cause #42). `storeHref` is
365
+ * `/stores/{slug}`, so `isClosedHref` resolves it through exactly the rule
366
+ * the nav, footer, sitemap and edge gate all use.
367
+ *
368
+ * `counts` is `undefined` on purpose โ€” a browse-page closure is a flag, not
369
+ * a measurement, so there is nothing for the rollup to contribute here.
370
+ *
371
+ * ๐Ÿ›‘ Hides the identity, NOT the data. `storeId` still reaches the cart, the
372
+ * order splitter and the payout path untouched; only the buyer-facing
373
+ * attribution is withdrawn. Nothing about how an order is routed changes.
374
+ */
375
+ const sellerIdentityVisible = !isClosedHref(storeHref, closedSurfaceContext(siteSettings, undefined));
376
+ const sellerName = sellerIdentityVisible ? safeSeller : null;
377
+ const sellerHref = sellerIdentityVisible ? storeHref : null;
351
378
  return (_jsxs(Main, { children: [_jsx(HistoryTracker, { productId: product.id, productType: "product", snapshot: {
352
379
  title: product.title,
353
380
  /*
@@ -404,9 +431,9 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
404
431
  emiAvailable: "EMI Available",
405
432
  wishlistCount: (n) => `${n} wishlisted`,
406
433
  categoryProductCount: (n, cat) => `${n} in ${cat}`,
407
- } }), productFeatures && features.length > 0 && (_jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures })), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this product" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => (_jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i))) })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller }), typeof p.storeRating === "number" && p.storeRating > 0 && (_jsx(Row, { gap: "xs", align: "center", className: "mt-0.5", children: _jsxs(Text, { size: "xs", color: "muted", children: [_jsx(Span, { className: "text-warning", "aria-hidden": "true", children: "\u2605" }), " ", p.storeRating.toFixed(1), typeof p.storeReviewCount === "number" &&
434
+ } }), productFeatures && features.length > 0 && (_jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures })), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this product" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => (_jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i))) })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), sellerName && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: sellerName }), typeof p.storeRating === "number" && p.storeRating > 0 && (_jsx(Row, { gap: "xs", align: "center", className: "mt-0.5", children: _jsxs(Text, { size: "xs", color: "muted", children: [_jsx(Span, { className: "text-warning", "aria-hidden": "true", children: "\u2605" }), " ", p.storeRating.toFixed(1), typeof p.storeReviewCount === "number" &&
408
435
  p.storeReviewCount > 0 &&
409
- ` ยท ${p.storeReviewCount} reviews`] }) }))] }), _jsxs(Row, { gap: "xs", align: "center", className: "shrink-0", children: [storeId && _jsx(MessageSellerButton, { storeId: storeId, productId: product.id }), storeHref && (_jsx(Link, { href: storeHref, className: "rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] })] }) }))] })), renderActions: () => (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [price !== null && (_jsxs(Div, { children: [_jsx(PriceDisplay, { amount: price, originalAmount: originalPrice ?? undefined, currency: currency, variant: "detail" }), siteSettings?.gst?.enabled === true &&
436
+ ` ยท ${p.storeReviewCount} reviews`] }) }))] }), _jsxs(Row, { gap: "xs", align: "center", className: "shrink-0", children: [sellerName && storeId && (_jsx(MessageSellerButton, { storeId: storeId, productId: product.id })), sellerHref && (_jsx(Link, { href: sellerHref, className: "rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] })] }) }))] })), renderActions: () => (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [price !== null && (_jsxs(Div, { children: [_jsx(PriceDisplay, { amount: price, originalAmount: originalPrice ?? undefined, currency: currency, variant: "detail" }), siteSettings?.gst?.enabled === true &&
410
437
  typeof p.gstRate === "number" &&
411
438
  p.gstRate > 0 && (_jsxs(Text, { size: "xs", color: "muted", children: ["Incl. ", p.gstRate, "% GST"] })), _jsx(MarketPriceAnchor, { band: priceBand?.byListingType?.[normalizeListingType(p)] ?? null, windowDays: priceBand?.windowDays ?? null, currency: currency, price: price }), inStock && effectiveStock !== null && effectiveStock <= 3 && (_jsxs(Text, { className: "mt-1 text-warning", size: "xs", children: ["Only ", effectiveStock, " left \u2014 order soon!"] })), inStock && effectiveStock !== null && effectiveStock > 3 && effectiveStock <= 10 && (_jsxs(Text, { className: "mt-1", color: "muted", size: "xs", children: [effectiveStock, " in stock"] })), madeToOrder && (_jsx(Text, { className: "mt-1", color: "muted", size: "xs", children: "Made to order \u2014 printed when you order, so it never runs out." })), partHref && (_jsxs(Text, { className: "mt-1", color: "muted", size: "xs", children: [partSlotLabel ?? "Part", ".", " ", _jsx(Link, { href: partHref, className: "text-primary-600 hover:underline", children: "See what else fits \u2192" }), fitsModels.length > 0 && (_jsxs(_Fragment, { children: [" ", "Fits:", " ", fitsModels.slice(0, 6).map((m, i) => (_jsxs(Text, { as: "span", children: [i > 0 ? ", " : "", m.categoryId ? (_jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(m.categoryId)), className: "text-primary-600 hover:underline", children: m.name })) : (m.name)] }, m.id)))] }))] }))] })), _jsxs(Stack, { gap: "sm", ...ctaAnchorProps(), children: [renderPrimaryActions ? (renderPrimaryActions({
412
439
  productId: product.id,
@@ -443,7 +470,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
443
470
  ? () => (_jsx(ShowGroupSection, { groupId: groupId, currentSlug: String(p.slug ?? product.id), isParent: isGroupParent, groupTitle: groupTitle }))
444
471
  : undefined, renderTabs: () => (_jsx(ProductTabsShell, { partsContent: compositionContent, descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: allSpecs.length > 0 ? (_jsx(SpecTable, { rows: allSpecs })) : undefined, ingredientsContent: ingredients.length > 0 ? (_jsx(Ul, { spacing: "comfortable", children: ingredients.map((item, i) => (_jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-1 flex-shrink-0 h-1.5 w-1.5 bg-primary-400", rounded: "full" }), item] }, i))) })) : undefined, howToUseContent: howToUse.length > 0 ? (_jsx(Ol, { spacing: "loose", children: howToUse.map((step, i) => (_jsxs(Li, { layout: "flex-start", gap: "3", textSize: "sm", color: "muted", children: [_jsx(Span, { layout: "flex-center", size: "xs", weight: "bold", className: "flex-shrink-0 h-6 w-6 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", children: i + 1 }), step] }, i))) })) : undefined, reviewsContent: _jsxs(Stack, { gap: "md", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." })] }), customTabs: toCustomSectionTabs(p.customSections) })), renderBundleSection: bundleMemberships.length > 0
445
472
  ? () => (_jsxs(Div, { className: CLS_BUNDLE_BOX, children: [_jsxs(Row, { className: "mb-3", align: "center", gap: "sm", children: [_jsx(Span, { size: "base", className: CLS_BUNDLE_ICON, "aria-hidden": "true", children: "\uD83D\uDCE6" }), _jsxs(Text, { className: CLS_BUNDLE_TITLE, children: ["Part of ", bundleMemberships.length === 1 ? "a bundle" : `${bundleMemberships.length} bundles`] })] }), _jsx(Stack, { gap: "3", children: bundleMemberships.map((b) => (_jsxs(Div, { className: CLS_BUNDLE_ROW, children: [_jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_BUNDLE_LABEL, children: "Included in bundle" }), _jsx(Text, { className: "truncate", color: "primary", size: "sm", weight: "semibold", children: b.title })] }), _jsx(Link, { href: String(ROUTES.PUBLIC.BUNDLE_DETAIL(b.id)), className: CLS_BUNDLE_CTA, children: "View Bundle \u2192" })] }, b.id))) })] }))
446
- : undefined, renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryNames[0] || categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] })) }), renderPrimaryActions
473
+ : undefined, renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryNames[0] || categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: sellerName ? storeName || undefined : undefined })] })) }), renderPrimaryActions
447
474
  ? renderPrimaryActions({
448
475
  productId: product.id,
449
476
  productSlug: product.slug ?? slug,