@tokenoftrust/storefront-runner 2.4.72 → 2.4.73
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/apps/storefront/src/lib/chrome/model.ts +5 -0
- package/apps/storefront/src/pages/products/[handle].astro +85 -18
- package/package.json +1 -1
- package/packages/public-runtime/src/chrome.ts +158 -0
- package/packages/public-runtime/src/index.ts +1 -0
- package/packages/public-runtime/src/product-details.ts +160 -0
|
@@ -48,6 +48,11 @@ export {
|
|
|
48
48
|
type ChromeHeader,
|
|
49
49
|
type ChromeFooter,
|
|
50
50
|
type ChromeConfig,
|
|
51
|
+
type ProductDetailsConfig,
|
|
52
|
+
type ProductDetailSpecRow,
|
|
53
|
+
type ProductAttributeLine,
|
|
54
|
+
type ProductMetafieldRef,
|
|
55
|
+
type ProductDetailFaqConfig,
|
|
51
56
|
type ChromeAssignmentEntry,
|
|
52
57
|
type ChromeAssignments,
|
|
53
58
|
type ChromeAssignmentsValidationResult,
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
resolveMembershipRule,
|
|
30
30
|
DEFAULT_STACKING_POLICY,
|
|
31
31
|
readReviewAggregate,
|
|
32
|
+
resolveProductDetails,
|
|
32
33
|
} from "@tot/public-runtime";
|
|
33
34
|
import type {
|
|
34
35
|
ProductPurchaseOption,
|
|
@@ -110,7 +111,7 @@ function titleCase(s: string) {
|
|
|
110
111
|
return s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
// Metafield reader.
|
|
114
|
+
// Metafield reader (ToT-internal namespaces only — compliance.* below).
|
|
114
115
|
const mf = (ns: string, key: string) =>
|
|
115
116
|
product.metafields.find((m) => m.namespace === ns && m.key === key)?.value;
|
|
116
117
|
|
|
@@ -120,9 +121,16 @@ const mf = (ns: string, key: string) =>
|
|
|
120
121
|
// unless BOTH numbers are real positives — so no fabricated rating can render.
|
|
121
122
|
const { average: reviewAvg, count: reviewCount } = readReviewAggregate(product);
|
|
122
123
|
|
|
124
|
+
// Declared PDP metafield->section mapping (ChromeConfig.productDetails,
|
|
125
|
+
// content/chrome.json). No metafield namespace/key convention is assumed by
|
|
126
|
+
// this template — a tenant's own catalog shape is declared, not guessed, so
|
|
127
|
+
// behavior never branches on which tenant is rendering.
|
|
128
|
+
const chrome = await Astro.locals.content.getChrome();
|
|
129
|
+
const details = resolveProductDetails(product.metafields, chrome.productDetails);
|
|
130
|
+
|
|
123
131
|
// Short description (merchant's concise summary) shown high on the PDP, like the
|
|
124
132
|
// source store. The longer description_html still renders below the buy column.
|
|
125
|
-
const shortDescription =
|
|
133
|
+
const shortDescription = details.shortDescription;
|
|
126
134
|
|
|
127
135
|
// OG image = the real product image (Shopify CDN supports ?width=), sized for
|
|
128
136
|
// social. Falls back to the tenant brand OG only when there's no usable image.
|
|
@@ -133,14 +141,14 @@ const ogImage =
|
|
|
133
141
|
: undefined;
|
|
134
142
|
|
|
135
143
|
// Product attributes shown the way the SOURCE STORE presents them: plain
|
|
136
|
-
// "Label: value" lines below the description
|
|
137
|
-
//
|
|
138
|
-
// are ToT-internal sync data and are NOT
|
|
139
|
-
// show them either (they gate
|
|
144
|
+
// "Label: value" lines below the description, sourced from F:-prefixed tags
|
|
145
|
+
// (flavor) + the tenant's declared attributeLines (e.g. VG % / Bottle Type).
|
|
146
|
+
// The compliance.* metafields are ToT-internal sync data and are NOT
|
|
147
|
+
// displayed — the source store doesn't show them either (they gate
|
|
148
|
+
// shipping/eligibility, not merchandising).
|
|
140
149
|
// Each flavor links to that flavor filtered within the product's collection —
|
|
141
150
|
// mirrors the source store's /collections/<handle>/<tag> pattern using our
|
|
142
|
-
// existing ?tag= PLP filter.
|
|
143
|
-
// "F: " flavor-tag convention is merchant-specific (same as the display above).
|
|
151
|
+
// existing ?tag= PLP filter.
|
|
144
152
|
const flavorCollection = product.collections[0];
|
|
145
153
|
const flavorTags = (product.tags ?? [])
|
|
146
154
|
.filter((t) => t.startsWith("F: "))
|
|
@@ -151,8 +159,7 @@ const flavorTags = (product.tags ?? [])
|
|
|
151
159
|
: undefined,
|
|
152
160
|
}))
|
|
153
161
|
.filter((f) => f.label);
|
|
154
|
-
const
|
|
155
|
-
const bottleType = mf("my_fields", "bottle_type");
|
|
162
|
+
const attributeLines = details.attributeLines;
|
|
156
163
|
|
|
157
164
|
// State sales restrictions (ToT compliance sync). Where this can't ship/sell.
|
|
158
165
|
let restrictedStates: string[] = [];
|
|
@@ -169,7 +176,6 @@ if (restrictedRaw) {
|
|
|
169
176
|
// Legal/safety disclaimer (scraped from the store's own PDP by the migration),
|
|
170
177
|
// per product category — sub-ohm on salts, battery on devices, etc. Selected by
|
|
171
178
|
// product_type (or the compliance.product_category metafield); default fallback.
|
|
172
|
-
const chrome = await Astro.locals.content.getChrome();
|
|
173
179
|
const productCategory = product.product_type || mf("compliance", "product_category");
|
|
174
180
|
const disclaimer =
|
|
175
181
|
(productCategory && chrome.disclaimers?.[productCategory]) || chrome.disclaimer;
|
|
@@ -440,7 +446,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
440
446
|
the longer description, then plain "Label: value" lines (no spec table). */}
|
|
441
447
|
<div class="mt-9 border-t border-[var(--color-border)] pt-7">
|
|
442
448
|
<div class="rte text-[var(--color-text)]" set:html={product.description_html} />
|
|
443
|
-
{(flavorTags.length > 0 ||
|
|
449
|
+
{(flavorTags.length > 0 || attributeLines.length > 0) && (
|
|
444
450
|
<div class="mt-5 space-y-1.5 text-sm leading-relaxed">
|
|
445
451
|
{flavorTags.length > 0 && (
|
|
446
452
|
<p>
|
|
@@ -457,12 +463,9 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
457
463
|
))}
|
|
458
464
|
</p>
|
|
459
465
|
)}
|
|
460
|
-
{
|
|
461
|
-
<p><span class="text-[var(--color-muted)]">
|
|
462
|
-
)}
|
|
463
|
-
{bottleType && (
|
|
464
|
-
<p><span class="text-[var(--color-muted)]">Bottle Type:</span> {bottleType}</p>
|
|
465
|
-
)}
|
|
466
|
+
{attributeLines.map((line) => (
|
|
467
|
+
<p><span class="text-[var(--color-muted)]">{line.label}:</span> {line.value}</p>
|
|
468
|
+
))}
|
|
466
469
|
</div>
|
|
467
470
|
)}
|
|
468
471
|
</div>
|
|
@@ -506,6 +509,70 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
506
509
|
</div>
|
|
507
510
|
</div>
|
|
508
511
|
|
|
512
|
+
{/* Declared PDP sections (spec rows, ingredients, FAQ, box contents, age
|
|
513
|
+
notice) — every row/section comes from the tenant's own
|
|
514
|
+
ChromeConfig.productDetails mapping (content/chrome.json), read above
|
|
515
|
+
into `details`. A section is simply absent when its declared metafield
|
|
516
|
+
has no value on this product — never an empty label. */}
|
|
517
|
+
{details.specRows.length > 0 && (
|
|
518
|
+
<Section title={details.specHeading} widget="product-details">
|
|
519
|
+
<dl class="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
520
|
+
{details.specRows.map((row) => (
|
|
521
|
+
<div class="border-b border-[var(--color-border)] pb-3">
|
|
522
|
+
<dt class="text-xs font-medium uppercase tracking-wide text-[var(--color-muted)]">{row.label}</dt>
|
|
523
|
+
<dd class="mt-1 text-sm font-medium text-[var(--color-text)]">{row.value}</dd>
|
|
524
|
+
</div>
|
|
525
|
+
))}
|
|
526
|
+
</dl>
|
|
527
|
+
</Section>
|
|
528
|
+
)}
|
|
529
|
+
|
|
530
|
+
{details.boxContents && (
|
|
531
|
+
<div class="container-prose">
|
|
532
|
+
<p class="text-sm leading-relaxed">
|
|
533
|
+
<span class="text-[var(--color-muted)]">{details.boxContents.label}:</span> {details.boxContents.value}
|
|
534
|
+
</p>
|
|
535
|
+
</div>
|
|
536
|
+
)}
|
|
537
|
+
|
|
538
|
+
{details.ingredients && (
|
|
539
|
+
<Section title={details.ingredients.heading} widget="product-ingredients" tone="surface">
|
|
540
|
+
<p class="text-sm leading-relaxed text-[var(--color-text)]">{details.ingredients.text}</p>
|
|
541
|
+
{details.factCheckedBy && (
|
|
542
|
+
<p class="mt-3 text-xs text-[var(--color-muted)]">{details.factCheckedBy}</p>
|
|
543
|
+
)}
|
|
544
|
+
</Section>
|
|
545
|
+
)}
|
|
546
|
+
|
|
547
|
+
{details.ageNotice && (
|
|
548
|
+
<div class="container-prose">
|
|
549
|
+
<aside
|
|
550
|
+
role="note"
|
|
551
|
+
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4"
|
|
552
|
+
>
|
|
553
|
+
<strong class="block text-sm font-display font-semibold text-[var(--color-text)]">
|
|
554
|
+
{details.ageNotice.title}
|
|
555
|
+
</strong>
|
|
556
|
+
<p class="mt-1 text-sm text-[var(--color-muted)]">{details.ageNotice.text}</p>
|
|
557
|
+
</aside>
|
|
558
|
+
</div>
|
|
559
|
+
)}
|
|
560
|
+
|
|
561
|
+
{details.faq && (
|
|
562
|
+
<Section title={details.faq.heading} widget="product-faq">
|
|
563
|
+
<div class="divide-y divide-[var(--color-border)]">
|
|
564
|
+
{details.faq.items.map((item) => (
|
|
565
|
+
<details class="group py-4">
|
|
566
|
+
<summary class="cursor-pointer list-none font-display text-sm font-medium text-[var(--color-text)]">
|
|
567
|
+
{item.question}
|
|
568
|
+
</summary>
|
|
569
|
+
<p class="mt-2 text-sm leading-relaxed text-[var(--color-muted)]">{item.answer}</p>
|
|
570
|
+
</details>
|
|
571
|
+
))}
|
|
572
|
+
</div>
|
|
573
|
+
</Section>
|
|
574
|
+
)}
|
|
575
|
+
|
|
509
576
|
{/* Customer reviews — real content migrated from the store's reviews app. */}
|
|
510
577
|
{reviews.length > 0 && (
|
|
511
578
|
<Section
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.73",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -407,6 +407,89 @@ export interface ChromeConfig {
|
|
|
407
407
|
* compliance.product_category) — the source store renders a different legal
|
|
408
408
|
* block per type (sub-ohm on salts, battery on devices). */
|
|
409
409
|
disclaimers?: Record<string, string>;
|
|
410
|
+
/**
|
|
411
|
+
* Declared PDP metafield->section mapping (see {@link ProductDetailsConfig}).
|
|
412
|
+
* Replaces any hardcoded metafield-namespace convention in the renderer: a
|
|
413
|
+
* tenant whose catalog carries spec/FAQ/ingredient data under its own
|
|
414
|
+
* namespace declares the mapping here, and the PDP renders it — never a
|
|
415
|
+
* namespace the template assumes by tenant id.
|
|
416
|
+
*/
|
|
417
|
+
productDetails?: ProductDetailsConfig;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** One "Product Details" spec-table row, sourced from one declared metafield. */
|
|
421
|
+
export interface ProductDetailSpecRow {
|
|
422
|
+
/** Metafield namespace, e.g. "custom". */
|
|
423
|
+
namespace: string;
|
|
424
|
+
/** Metafield key, e.g. "dv_puff_count". */
|
|
425
|
+
key: string;
|
|
426
|
+
/** Row label, e.g. "Puff Count". */
|
|
427
|
+
label: string;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** One declared "Label: value" prose line below the description. */
|
|
431
|
+
export interface ProductAttributeLine {
|
|
432
|
+
namespace: string;
|
|
433
|
+
key: string;
|
|
434
|
+
label: string;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** A metafield reference (namespace + key), no label — used where the section
|
|
438
|
+
* itself supplies the heading/copy. */
|
|
439
|
+
export interface ProductMetafieldRef {
|
|
440
|
+
namespace: string;
|
|
441
|
+
key: string;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* FAQ entries stored as a fixed-width set of numbered metafields
|
|
446
|
+
* (`faq_q1`/`faq_a1` .. `faq_qN`/`faq_aN`) — the convention a source store's
|
|
447
|
+
* migrated FAQ block commonly uses. `{n}` in each pattern is replaced with
|
|
448
|
+
* 1..maxItems; a product missing a given pair simply has no entry for that n.
|
|
449
|
+
*/
|
|
450
|
+
export interface ProductDetailFaqConfig {
|
|
451
|
+
namespace: string;
|
|
452
|
+
/** Question key template containing a literal `{n}`, e.g. "faq_q{n}". */
|
|
453
|
+
questionKeyPattern: string;
|
|
454
|
+
/** Answer key template containing a literal `{n}`, e.g. "faq_a{n}". */
|
|
455
|
+
answerKeyPattern: string;
|
|
456
|
+
/** Highest n to probe (1-based). */
|
|
457
|
+
maxItems: number;
|
|
458
|
+
/** Section heading. Defaults to "Frequently Asked Questions". */
|
|
459
|
+
heading?: string;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Declared mapping from a tenant's own catalog metafield namespace/keys to PDP
|
|
464
|
+
* sections. Every section is OMITTED when its declared metafield has no value
|
|
465
|
+
* on a given product — never rendered with an empty label. Undeclared
|
|
466
|
+
* metafields are never read: only what's listed here reaches the page.
|
|
467
|
+
*/
|
|
468
|
+
export interface ProductDetailsConfig {
|
|
469
|
+
/** Ordered fallback list for the short summary shown near the buy button
|
|
470
|
+
* (first metafield with a value wins). */
|
|
471
|
+
shortDescription?: ProductMetafieldRef[];
|
|
472
|
+
/** Ordered "Label: value" prose lines below the long description. */
|
|
473
|
+
attributeLines?: ProductAttributeLine[];
|
|
474
|
+
/** "Product Details" spec-table/grid rows, in declared order. */
|
|
475
|
+
specRows?: ProductDetailSpecRow[];
|
|
476
|
+
/** Heading for the spec-row section. Defaults to "Product Details". */
|
|
477
|
+
specHeading?: string;
|
|
478
|
+
/** The ingredients paragraph metafield. */
|
|
479
|
+
ingredients?: ProductMetafieldRef & { heading?: string };
|
|
480
|
+
/** Byline under the ingredients text, e.g. "Fact Checked By: Staff". Static
|
|
481
|
+
* copy (not a metafield); shown only when `ingredients` resolves to a value. */
|
|
482
|
+
factCheckedBy?: string;
|
|
483
|
+
/**
|
|
484
|
+
* Age/compliance notice (e.g. "Adult Use Only (21+)"), rendered only when
|
|
485
|
+
* its declared metafield has a value. Copy is declared here, never a fixed
|
|
486
|
+
* string in the template, so it's accurate for any tenant/jurisdiction.
|
|
487
|
+
*/
|
|
488
|
+
ageNotice?: ProductMetafieldRef & { title: string; text: string };
|
|
489
|
+
/** FAQ block sourced from numbered metafields. */
|
|
490
|
+
faq?: ProductDetailFaqConfig;
|
|
491
|
+
/** Box/package-contents line. */
|
|
492
|
+
boxContents?: ProductMetafieldRef & { label?: string };
|
|
410
493
|
}
|
|
411
494
|
|
|
412
495
|
// ---------------------------------------------------------------------------
|
|
@@ -726,6 +809,80 @@ function validateFooter(v: unknown, path: string, errors: string[]): v is Chrome
|
|
|
726
809
|
return errors.length === before;
|
|
727
810
|
}
|
|
728
811
|
|
|
812
|
+
function validateMetafieldRef(v: unknown, path: string, errors: string[]): void {
|
|
813
|
+
if (!isObject(v)) {
|
|
814
|
+
errors.push(`${path}: must be an object`);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (!isNonEmptyString(v.namespace)) errors.push(`${path}.namespace: required, non-empty string`);
|
|
818
|
+
if (!isNonEmptyString(v.key)) errors.push(`${path}.key: required, non-empty string`);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function validateProductDetails(v: unknown, path: string, errors: string[]): void {
|
|
822
|
+
if (!isObject(v)) {
|
|
823
|
+
errors.push(`${path}: must be an object`);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (v.shortDescription !== undefined) {
|
|
827
|
+
if (!Array.isArray(v.shortDescription)) errors.push(`${path}.shortDescription: must be an array`);
|
|
828
|
+
else v.shortDescription.forEach((r, i) => validateMetafieldRef(r, `${path}.shortDescription[${i}]`, errors));
|
|
829
|
+
}
|
|
830
|
+
if (v.attributeLines !== undefined) {
|
|
831
|
+
if (!Array.isArray(v.attributeLines)) errors.push(`${path}.attributeLines: must be an array`);
|
|
832
|
+
else
|
|
833
|
+
v.attributeLines.forEach((r, i) => {
|
|
834
|
+
validateMetafieldRef(r, `${path}.attributeLines[${i}]`, errors);
|
|
835
|
+
if (isObject(r) && !isNonEmptyString(r.label)) {
|
|
836
|
+
errors.push(`${path}.attributeLines[${i}].label: required, non-empty string`);
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
if (v.specRows !== undefined) {
|
|
841
|
+
if (!Array.isArray(v.specRows)) errors.push(`${path}.specRows: must be an array`);
|
|
842
|
+
else
|
|
843
|
+
v.specRows.forEach((r, i) => {
|
|
844
|
+
validateMetafieldRef(r, `${path}.specRows[${i}]`, errors);
|
|
845
|
+
if (isObject(r) && !isNonEmptyString(r.label)) {
|
|
846
|
+
errors.push(`${path}.specRows[${i}].label: required, non-empty string`);
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
checkOptionalString(v.specHeading, `${path}.specHeading`, errors);
|
|
851
|
+
if (v.ingredients !== undefined) {
|
|
852
|
+
validateMetafieldRef(v.ingredients, `${path}.ingredients`, errors);
|
|
853
|
+
if (isObject(v.ingredients)) checkOptionalString(v.ingredients.heading, `${path}.ingredients.heading`, errors);
|
|
854
|
+
}
|
|
855
|
+
checkOptionalString(v.factCheckedBy, `${path}.factCheckedBy`, errors);
|
|
856
|
+
if (v.ageNotice !== undefined) {
|
|
857
|
+
validateMetafieldRef(v.ageNotice, `${path}.ageNotice`, errors);
|
|
858
|
+
if (isObject(v.ageNotice)) {
|
|
859
|
+
if (!isNonEmptyString(v.ageNotice.title)) errors.push(`${path}.ageNotice.title: required, non-empty string`);
|
|
860
|
+
if (!isNonEmptyString(v.ageNotice.text)) errors.push(`${path}.ageNotice.text: required, non-empty string`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (v.faq !== undefined) {
|
|
864
|
+
if (!isObject(v.faq)) {
|
|
865
|
+
errors.push(`${path}.faq: must be an object`);
|
|
866
|
+
} else {
|
|
867
|
+
if (!isNonEmptyString(v.faq.namespace)) errors.push(`${path}.faq.namespace: required, non-empty string`);
|
|
868
|
+
if (!isNonEmptyString(v.faq.questionKeyPattern) || !v.faq.questionKeyPattern.includes("{n}")) {
|
|
869
|
+
errors.push(`${path}.faq.questionKeyPattern: required string containing "{n}"`);
|
|
870
|
+
}
|
|
871
|
+
if (!isNonEmptyString(v.faq.answerKeyPattern) || !v.faq.answerKeyPattern.includes("{n}")) {
|
|
872
|
+
errors.push(`${path}.faq.answerKeyPattern: required string containing "{n}"`);
|
|
873
|
+
}
|
|
874
|
+
if (typeof v.faq.maxItems !== "number" || !Number.isInteger(v.faq.maxItems) || v.faq.maxItems < 1) {
|
|
875
|
+
errors.push(`${path}.faq.maxItems: required positive integer`);
|
|
876
|
+
}
|
|
877
|
+
checkOptionalString(v.faq.heading, `${path}.faq.heading`, errors);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
if (v.boxContents !== undefined) {
|
|
881
|
+
validateMetafieldRef(v.boxContents, `${path}.boxContents`, errors);
|
|
882
|
+
if (isObject(v.boxContents)) checkOptionalString(v.boxContents.label, `${path}.boxContents.label`, errors);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
729
886
|
/**
|
|
730
887
|
* Validate `raw` (parsed JSON) as a `ChromeConfig`. Collects every violation
|
|
731
888
|
* (not just the first) so a caller gets one actionable report. Trusts the
|
|
@@ -754,6 +911,7 @@ export function validateChromeConfig(raw: unknown): ChromeConfigValidationResult
|
|
|
754
911
|
if (!isObject(raw.breadcrumbs)) errors.push("breadcrumbs: must be an object");
|
|
755
912
|
else checkOptionalString(raw.breadcrumbs.homeLabel, "breadcrumbs.homeLabel", errors);
|
|
756
913
|
}
|
|
914
|
+
if (raw.productDetails !== undefined) validateProductDetails(raw.productDetails, "productDetails", errors);
|
|
757
915
|
|
|
758
916
|
if (errors.length > 0) return { ok: false, config: null, errors };
|
|
759
917
|
return { ok: true, config: raw as unknown as ChromeConfig, errors: [] };
|
|
@@ -50,6 +50,7 @@ export * from "./customization-versioning.js";
|
|
|
50
50
|
export * from "./customization-scripts.js";
|
|
51
51
|
export * from "./customization-reconcile.js";
|
|
52
52
|
export * from "./chrome.js";
|
|
53
|
+
export * from "./product-details.js";
|
|
53
54
|
// Declarative interaction tracking: the data-tot-el / -action / -placement
|
|
54
55
|
// attribute contract, its single emit path, and the ONE delegated click
|
|
55
56
|
// listener both document mounts install. Reads declared attributes only —
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a product's declared PDP sections (spec rows, ingredients, FAQ, box
|
|
3
|
+
* contents, age notice, short description, attribute lines) from its raw
|
|
4
|
+
* metafields plus the tenant's declared `ChromeConfig.productDetails` mapping.
|
|
5
|
+
*
|
|
6
|
+
* Pure and dependency-free: no namespace/key convention is read unless the
|
|
7
|
+
* tenant's own `productDetails` config names it, and a section whose declared
|
|
8
|
+
* metafield has no value on a given product is OMITTED entirely — never
|
|
9
|
+
* rendered with an empty label. This is what replaces a renderer hardcoding
|
|
10
|
+
* one merchant's metafield-namespace convention (e.g. "my_fields").
|
|
11
|
+
*/
|
|
12
|
+
import type {
|
|
13
|
+
ProductDetailsConfig,
|
|
14
|
+
ProductDetailSpecRow,
|
|
15
|
+
ProductAttributeLine,
|
|
16
|
+
} from "./chrome.js";
|
|
17
|
+
import type { Metafield } from "./product.js";
|
|
18
|
+
|
|
19
|
+
export interface ResolvedSpecRow {
|
|
20
|
+
label: string;
|
|
21
|
+
value: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ResolvedAttributeLine {
|
|
25
|
+
label: string;
|
|
26
|
+
value: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResolvedFaqItem {
|
|
30
|
+
question: string;
|
|
31
|
+
answer: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ResolvedProductDetails {
|
|
35
|
+
shortDescription?: string;
|
|
36
|
+
attributeLines: ResolvedAttributeLine[];
|
|
37
|
+
specHeading: string;
|
|
38
|
+
specRows: ResolvedSpecRow[];
|
|
39
|
+
ingredients?: { heading: string; text: string };
|
|
40
|
+
factCheckedBy?: string;
|
|
41
|
+
ageNotice?: { title: string; text: string };
|
|
42
|
+
faq?: { heading: string; items: ResolvedFaqItem[] };
|
|
43
|
+
boxContents?: { label: string; value: string };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_SPEC_HEADING = "Product Details";
|
|
47
|
+
const DEFAULT_FAQ_HEADING = "Frequently Asked Questions";
|
|
48
|
+
const DEFAULT_BOX_CONTENTS_LABEL = "What's in the box";
|
|
49
|
+
|
|
50
|
+
function metafieldLookup(metafields: readonly Metafield[]): (namespace: string, key: string) => string | undefined {
|
|
51
|
+
const map = new Map<string, string>();
|
|
52
|
+
for (const m of metafields) {
|
|
53
|
+
const v = m.value?.trim();
|
|
54
|
+
if (v) map.set(`${m.namespace}.${m.key}`, v);
|
|
55
|
+
}
|
|
56
|
+
return (namespace, key) => map.get(`${namespace}.${key}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a product's declared sections. `config` undefined/empty => every
|
|
61
|
+
* section is absent (no fallback to any hardcoded namespace).
|
|
62
|
+
*/
|
|
63
|
+
export function resolveProductDetails(
|
|
64
|
+
metafields: readonly Metafield[] | undefined,
|
|
65
|
+
config: ProductDetailsConfig | undefined,
|
|
66
|
+
): ResolvedProductDetails {
|
|
67
|
+
const mf = metafieldLookup(metafields ?? []);
|
|
68
|
+
const out: ResolvedProductDetails = {
|
|
69
|
+
attributeLines: [],
|
|
70
|
+
specHeading: config?.specHeading?.trim() || DEFAULT_SPEC_HEADING,
|
|
71
|
+
specRows: [],
|
|
72
|
+
};
|
|
73
|
+
if (!config) return out;
|
|
74
|
+
|
|
75
|
+
if (config.shortDescription) {
|
|
76
|
+
for (const ref of config.shortDescription) {
|
|
77
|
+
const value = mf(ref.namespace, ref.key);
|
|
78
|
+
if (value) {
|
|
79
|
+
out.shortDescription = value;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (config.attributeLines) {
|
|
86
|
+
out.attributeLines = resolveAttributeLines(config.attributeLines, mf);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (config.specRows) {
|
|
90
|
+
out.specRows = resolveSpecRows(config.specRows, mf);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (config.ingredients) {
|
|
94
|
+
const value = mf(config.ingredients.namespace, config.ingredients.key);
|
|
95
|
+
if (value) {
|
|
96
|
+
out.ingredients = { heading: config.ingredients.heading?.trim() || "Ingredients", text: value };
|
|
97
|
+
if (config.factCheckedBy?.trim()) out.factCheckedBy = config.factCheckedBy.trim();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (config.ageNotice) {
|
|
102
|
+
const value = mf(config.ageNotice.namespace, config.ageNotice.key);
|
|
103
|
+
if (value) out.ageNotice = { title: config.ageNotice.title, text: config.ageNotice.text };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (config.faq) {
|
|
107
|
+
const items = resolveFaq(config.faq, mf);
|
|
108
|
+
if (items.length > 0) {
|
|
109
|
+
out.faq = { heading: config.faq.heading?.trim() || DEFAULT_FAQ_HEADING, items };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (config.boxContents) {
|
|
114
|
+
const value = mf(config.boxContents.namespace, config.boxContents.key);
|
|
115
|
+
if (value) {
|
|
116
|
+
out.boxContents = { label: config.boxContents.label?.trim() || DEFAULT_BOX_CONTENTS_LABEL, value };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resolveAttributeLines(
|
|
124
|
+
lines: readonly ProductAttributeLine[],
|
|
125
|
+
mf: (namespace: string, key: string) => string | undefined,
|
|
126
|
+
): ResolvedAttributeLine[] {
|
|
127
|
+
const out: ResolvedAttributeLine[] = [];
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
const value = mf(line.namespace, line.key);
|
|
130
|
+
if (value) out.push({ label: line.label, value });
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveSpecRows(
|
|
136
|
+
rows: readonly ProductDetailSpecRow[],
|
|
137
|
+
mf: (namespace: string, key: string) => string | undefined,
|
|
138
|
+
): ResolvedSpecRow[] {
|
|
139
|
+
const out: ResolvedSpecRow[] = [];
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
const value = mf(row.namespace, row.key);
|
|
142
|
+
if (value) out.push({ label: row.label, value });
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveFaq(
|
|
148
|
+
faq: NonNullable<ProductDetailsConfig["faq"]>,
|
|
149
|
+
mf: (namespace: string, key: string) => string | undefined,
|
|
150
|
+
): ResolvedFaqItem[] {
|
|
151
|
+
const items: ResolvedFaqItem[] = [];
|
|
152
|
+
for (let n = 1; n <= faq.maxItems; n++) {
|
|
153
|
+
const qKey = faq.questionKeyPattern.replace("{n}", String(n));
|
|
154
|
+
const aKey = faq.answerKeyPattern.replace("{n}", String(n));
|
|
155
|
+
const question = mf(faq.namespace, qKey);
|
|
156
|
+
const answer = mf(faq.namespace, aKey);
|
|
157
|
+
if (question && answer) items.push({ question, answer });
|
|
158
|
+
}
|
|
159
|
+
return items;
|
|
160
|
+
}
|