@tokenoftrust/storefront-runner 2.4.72 → 2.4.74

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.
@@ -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
+ }