@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.
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.68.0",
3
+ "version": "4.69.1",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"