@mohasinac/appkit 4.68.0 → 4.69.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/seo/sitemap.js +20 -1
- package/dist/_internal/server/features/site-settings/adapters.js +18 -1
- package/dist/_internal/shared/features/site-settings/types.d.ts +24 -0
- package/dist/_internal/shared/listing-types/feature-flags.d.ts +12 -0
- package/dist/_internal/shared/listing-types/feature-flags.js +14 -0
- package/dist/_internal/shared/listing-types/hidden-surfaces.d.ts +83 -14
- package/dist/_internal/shared/listing-types/hidden-surfaces.js +127 -14
- package/dist/_internal/shared/listing-types/route-gating.d.ts +67 -25
- package/dist/_internal/shared/listing-types/route-gating.js +71 -27
- package/dist/features/about/components/HelpPageView.js +19 -1
- package/dist/features/admin/schemas/firestore.d.ts +23 -0
- package/dist/features/admin/schemas/firestore.js +69 -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
|
@@ -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,
|