@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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/seo/sitemap.js +27 -13
- package/dist/_internal/server/features/site-settings/adapters.js +27 -0
- package/dist/_internal/server/functions/firestore.d.ts +18 -1
- package/dist/_internal/server/functions/firestore.js +25 -1
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.d.ts +53 -0
- package/dist/_internal/server/jobs/handlers/onSiteSettingsWrite.js +63 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +46 -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 +112 -0
- package/dist/_internal/shared/listing-types/hidden-surfaces.js +170 -0
- 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
|
@@ -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[];
|
|
@@ -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
|
|
3
|
-
*
|
|
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
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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[],
|
|
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
|
|
3
|
-
*
|
|
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 {
|
|
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,
|
|
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) =>
|
|
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,
|
|
68
|
-
if (
|
|
73
|
+
function isSlideDisabled(slide, ctx) {
|
|
74
|
+
if (isClosedHref(slide.overlay?.button?.link, ctx))
|
|
69
75
|
return true;
|
|
70
|
-
if (
|
|
76
|
+
if (isClosedHref(slide.link?.url, ctx))
|
|
71
77
|
return true;
|
|
72
78
|
return false;
|
|
73
79
|
}
|
|
74
80
|
/**
|
|
75
|
-
* Filter a slide list
|
|
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
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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,
|
|
88
|
+
export function gateCarouselSlides(slides, ctx) {
|
|
83
89
|
const kept = [];
|
|
84
90
|
for (const slide of slides) {
|
|
85
|
-
if (isSlideDisabled(slide,
|
|
91
|
+
if (isSlideDisabled(slide, ctx))
|
|
86
92
|
continue;
|
|
87
|
-
const cards = (slide.cards ?? []).filter((card) => !isCardDisabled(card,
|
|
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" })),
|
|
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 }),
|
|
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,
|