@akinon/pz-theme 2.0.36-rc.0 → 2.0.37-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +16 -31
  2. package/jest/__mocks__/hookform-resolvers.js +5 -0
  3. package/jest/__mocks__/next-auth-react.js +5 -0
  4. package/jest.config.js +3 -0
  5. package/package.json +4 -4
  6. package/src/blocks/accordion-block.tsx +35 -3
  7. package/src/blocks/commerce-format.ts +220 -0
  8. package/src/blocks/icon-block.tsx +6 -1
  9. package/src/blocks/image-gallery-block.tsx +1 -1
  10. package/src/blocks/link-block.tsx +9 -1
  11. package/src/blocks/slider-block.tsx +2 -1
  12. package/src/blocks/tab-block.tsx +15 -2
  13. package/src/blocks/video-block.tsx +6 -0
  14. package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
  15. package/src/components/action-toolbar.tsx +182 -3
  16. package/src/components/part-state-preview.tsx +92 -0
  17. package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
  18. package/src/hooks/use-designer-features.tsx +16 -1
  19. package/src/hooks/use-overlay.ts +538 -0
  20. package/src/hooks/use-visibility-context.ts +14 -2
  21. package/src/sections/__tests__/divider-section.test.tsx +28 -1
  22. package/src/sections/contact-form-section.tsx +4 -1
  23. package/src/sections/coupon-banner-section.tsx +2 -1
  24. package/src/sections/featured-product-spotlight-section.tsx +4 -4
  25. package/src/sections/find-in-store-section.tsx +2 -1
  26. package/src/sections/hover-showcase-section.tsx +2 -1
  27. package/src/sections/installment-options-section.tsx +2 -1
  28. package/src/sections/order-tracking-lookup-section.tsx +6 -1
  29. package/src/sections/posts-slider-section.tsx +8 -3
  30. package/src/sections/pre-order-launch-banner-section.tsx +2 -1
  31. package/src/sections/tabs-section.tsx +7 -13
  32. package/src/theme-block.tsx +81 -16
  33. package/src/theme-placeholder-client.tsx +8 -1
  34. package/src/theme-placeholder-wrapper.tsx +17 -3
  35. package/src/theme-placeholder.tsx +51 -5
  36. package/src/theme-section.tsx +20 -4
  37. package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
  38. package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
  39. package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
  40. package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
  41. package/src/utils/breakpoints.ts +116 -0
  42. package/src/utils/custom-css.ts +233 -0
  43. package/src/utils/index.ts +353 -42
  44. package/src/utils/iterator-utils.ts +675 -62
  45. package/src/utils/page-context-discovery.test.ts +188 -0
  46. package/src/utils/page-context-discovery.ts +48 -4
  47. package/src/utils/part-styles.ts +229 -0
  48. package/src/utils/row-visibility.ts +273 -0
  49. package/src/utils/visibility-rules.test.ts +579 -0
  50. package/src/utils/visibility-rules.ts +346 -3
@@ -1,16 +1,31 @@
1
- import { Block } from '../theme-block';
1
+ // Type-only: the CSS generator imports the binding-token guard from here, and
2
+ // a value import of the block module would close that loop at runtime.
3
+ import type { Block } from '../theme-block';
2
4
  import { devWarn } from './dev-warn';
5
+ import { isVisibleForItem, readRowVisibility } from './row-visibility';
3
6
 
4
7
  interface BindingContext {
5
8
  sectionDataSource?: any;
6
9
  pageContext?: Record<string, unknown> | null;
7
10
  isDesigner?: boolean;
11
+ /**
12
+ * Locale for the money a binding resolves to. Bound prices used to be
13
+ * formatted as tr-TR whatever the storefront's locale was, so a brand
14
+ * running in en-GB got Turkish grouping in every repeated row.
15
+ */
16
+ locale?: string;
8
17
  }
9
18
 
10
19
  interface IteratorOptions extends BindingContext {
11
20
  iteratorBlock: Block;
12
21
  forceIteratorCount?: number;
13
22
  forceIteratorOffset?: number;
23
+ /**
24
+ * Rows supplied by the caller instead of looked up on the section's data
25
+ * source. Functional blocks fetch their own list (basket, orders, stores),
26
+ * so they have the items in hand and no data-source path to point at.
27
+ */
28
+ items?: Record<string, unknown>[];
14
29
  }
15
30
 
16
31
  const PAGE_CONTEXT_DATA_SOURCE_TYPE = 'page-context';
@@ -26,6 +41,97 @@ const DEFAULT_BINDING_TARGETS: Record<string, string> = {
26
41
  const isRecord = (value: unknown): value is Record<string, unknown> =>
27
42
  typeof value === 'object' && value !== null && !Array.isArray(value);
28
43
 
44
+ /**
45
+ * Binding tokens: `{{item.badgeColor}}`.
46
+ *
47
+ * A binding used to be a bare path (`item.name`) that replaced the whole
48
+ * value, and a bare path still does exactly that — image URLs, booleans and
49
+ * numbers all depend on getting the RAW value back rather than its string
50
+ * form. A token is the additive spelling: several of them can sit inside one
51
+ * value next to literal text, which is what lets a single text block say
52
+ * "Ürün Kodu {{item.base_code}}.{{item.color}}" instead of three siblings
53
+ * fighting over the spacing, and what lets a style value carry a colour that
54
+ * comes from the row.
55
+ *
56
+ * `{{path}}` on its own is the bare path spelled differently, so it is
57
+ * normalized back to the bare path and keeps the raw-value contract.
58
+ */
59
+ const BINDING_TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*\}\}/g;
60
+ const SINGLE_BINDING_TOKEN_PATTERN = /^\{\{\s*([^{}]+?)\s*\}\}$/;
61
+ const CONTAINS_BINDING_TOKEN = /\{\{\s*[^{}]+?\s*\}\}/;
62
+
63
+ /**
64
+ * True for a value that still carries an unresolved binding token. Exported
65
+ * because the CSS generator has to leave such a value out of the stylesheet:
66
+ * a token is an instruction, not a CSS value.
67
+ */
68
+ export const hasBindingToken = (value: unknown): boolean =>
69
+ typeof value === 'string' && CONTAINS_BINDING_TOKEN.test(value);
70
+
71
+ const readBindingTokens = (value: string): string[] =>
72
+ Array.from(value.matchAll(BINDING_TOKEN_PATTERN), (match) =>
73
+ String(match[1]).trim()
74
+ );
75
+
76
+ /** `{{path}}` alone means the same as the bare `path` the editor writes today. */
77
+ const normalizeBindingExpression = (binding: string): string => {
78
+ const single = binding.match(SINGLE_BINDING_TOKEN_PATTERN);
79
+ return single ? single[1].trim() : binding;
80
+ };
81
+
82
+ /**
83
+ * `item.colour` names a field of the row; a bare `item` names the row itself.
84
+ * The bare form is the only way to address an entry of a list of plain values
85
+ * (`tags: ['new','sale']`) — a repeat over one produces a clone per entry, and
86
+ * without this the entry has no spelling, so every clone prints the authored
87
+ * fallback. The empty path reads back as the row (see `getValueAtPath`).
88
+ */
89
+ const stripItemPrefix = (path: string): string =>
90
+ path === 'item' ? '' : path.replace(/^item\./, '');
91
+
92
+ /** A single token addresses the repeated row when it reads `item` or `item.*`. */
93
+ const isRowScopedToken = (token: string): boolean =>
94
+ token === 'item' || token.startsWith('item.');
95
+
96
+ /**
97
+ * Whether a binding addresses the repeated row rather than the section's data
98
+ * source. For a bare path this is the `item` test the tokens already use;
99
+ * a token expression is row-scoped as soon as one of its tokens is.
100
+ */
101
+ const isRowScopedBinding = (binding: string): boolean => {
102
+ if (isRowScopedToken(binding)) return true;
103
+ if (!hasBindingToken(binding)) return false;
104
+
105
+ return readBindingTokens(binding).some(isRowScopedToken);
106
+ };
107
+
108
+ /**
109
+ * Replaces every token with its resolved text and keeps the literal text
110
+ * around it. Returns undefined when nothing resolved, so the block keeps the
111
+ * value the designer authored as a fallback — which is what an unresolved bare
112
+ * path does today.
113
+ */
114
+ const interpolateBindingTemplate = (
115
+ template: string,
116
+ resolveToken: (path: string) => unknown,
117
+ toText: (value: unknown) => string | undefined
118
+ ): string | undefined => {
119
+ let resolvedCount = 0;
120
+
121
+ const text = template.replace(
122
+ BINDING_TOKEN_PATTERN,
123
+ (_match, rawPath: string) => {
124
+ const asText = toText(resolveToken(String(rawPath).trim()));
125
+ if (asText === undefined) return '';
126
+
127
+ resolvedCount += 1;
128
+ return asText;
129
+ }
130
+ );
131
+
132
+ return resolvedCount > 0 ? text : undefined;
133
+ };
134
+
29
135
  const getValueAtPath = (source: unknown, path: string): unknown => {
30
136
  if (!path.trim()) return source;
31
137
 
@@ -239,10 +345,70 @@ const escapeHtml = (value: string): string =>
239
345
  .replace(/"/g, '&quot;')
240
346
  .replace(/'/g, '&#39;');
241
347
 
348
+ /**
349
+ * Which items a repeat actually draws, and the count it falls back to when
350
+ * there are none.
351
+ *
352
+ * Exported because the product row scope has to hand clone N the row clone N
353
+ * was built from: with the count properties set, clone N is NOT items[N]. Two
354
+ * copies of this arithmetic drift the moment one of them learns a new
355
+ * property, and the symptom — every card showing the neighbouring product's
356
+ * price — is the kind nobody reads as an off-by-offset.
357
+ */
358
+ export const sliceIteratorItems = (
359
+ iteratorBlock: Block,
360
+ items: Record<string, unknown>[],
361
+ overrides: {
362
+ forceIteratorCount?: number;
363
+ forceIteratorOffset?: number;
364
+ } = {}
365
+ ): { items: Record<string, unknown>[]; count: number; offset: number } => {
366
+ const { forceIteratorCount, forceIteratorOffset } = overrides;
367
+
368
+ const useIteratorCount =
369
+ forceIteratorCount !== undefined
370
+ ? true
371
+ : iteratorBlock.properties?.useIteratorCount === true ||
372
+ iteratorBlock.properties?.useIteratorCount === 'true';
373
+ const count =
374
+ forceIteratorCount !== undefined
375
+ ? Math.max(1, Math.floor(forceIteratorCount))
376
+ : parsePositiveNumber(iteratorBlock.properties?.iteratorCount, 1);
377
+ const offset =
378
+ forceIteratorOffset !== undefined
379
+ ? Math.max(0, Math.floor(forceIteratorOffset))
380
+ : parsePositiveNumber(iteratorBlock.properties?.iteratorOffset, 0);
381
+
382
+ return {
383
+ items:
384
+ items.length > 0
385
+ ? useIteratorCount
386
+ ? items.slice(offset, offset + count)
387
+ : items
388
+ : [],
389
+ count,
390
+ offset
391
+ };
392
+ };
393
+
394
+ /**
395
+ * The storefront's locale, set once per render pass by the section that owns
396
+ * the bindings. A module-level value rather than a parameter because the
397
+ * formatting sits deep inside a recursive value walk, and threading it
398
+ * through every call would touch a dozen signatures for one string.
399
+ */
400
+ let bindingLocale = 'tr-TR';
401
+
402
+ export const setBindingLocale = (locale?: string) => {
403
+ if (typeof locale === 'string' && locale.trim()) {
404
+ bindingLocale = locale.trim();
405
+ }
406
+ };
407
+
242
408
  const formatDisplayPrice = (value: unknown): string => {
243
409
  if (typeof value === 'number' && Number.isFinite(value)) {
244
410
  const hasDecimals = Math.abs(value % 1) > 0.00001;
245
- return value.toLocaleString('tr-TR', {
411
+ return value.toLocaleString(bindingLocale, {
246
412
  minimumFractionDigits: hasDecimals ? 2 : 0,
247
413
  maximumFractionDigits: 2
248
414
  });
@@ -255,7 +421,7 @@ const formatDisplayPrice = (value: unknown): string => {
255
421
 
256
422
  const parsed = parsePriceValue(raw);
257
423
  const hasDecimals = Math.abs(parsed % 1) > 0.00001;
258
- return parsed.toLocaleString('tr-TR', {
424
+ return parsed.toLocaleString(bindingLocale, {
259
425
  minimumFractionDigits: hasDecimals ? 2 : 0,
260
426
  maximumFractionDigits: 2
261
427
  });
@@ -403,20 +569,41 @@ export const resolveBlockBindings = ({
403
569
  : sourceBlock.properties
404
570
  };
405
571
 
406
- const bindingPath =
572
+ const bindingPath = normalizeBindingExpression(
407
573
  typeof nextBlock.properties?.dataBinding === 'string'
408
574
  ? nextBlock.properties.dataBinding.trim()
409
- : '';
575
+ : ''
576
+ );
410
577
 
411
578
  let resolvedBlock = nextBlock;
412
- if (bindingRoot && bindingPath && !bindingPath.startsWith('item.')) {
413
- const resolvedValue = getValueAtPath(bindingRoot, bindingPath);
414
- if (resolvedValue !== undefined) {
415
- resolvedBlock = applyResolvedBinding(
416
- nextBlock,
417
- resolvedValue,
418
- bindingPath
579
+ // A row-scoped binding is left alone here exactly as before: the iterator
580
+ // already resolved it against the row, and re-resolving it against the
581
+ // section would blank the value back out.
582
+ if (bindingRoot && bindingPath && !isRowScopedBinding(bindingPath)) {
583
+ if (hasBindingToken(bindingPath)) {
584
+ const target = getItemBindingTarget(nextBlock);
585
+ const interpolated = interpolateBindingTemplate(
586
+ bindingPath,
587
+ (path) => getValueAtPath(bindingRoot, path),
588
+ (value) => normalizeScalarValue(value, target, nextBlock.type)
419
589
  );
590
+
591
+ if (interpolated !== undefined) {
592
+ resolvedBlock = applyResolvedBinding(
593
+ nextBlock,
594
+ interpolated,
595
+ bindingPath
596
+ );
597
+ }
598
+ } else {
599
+ const resolvedValue = getValueAtPath(bindingRoot, bindingPath);
600
+ if (resolvedValue !== undefined) {
601
+ resolvedBlock = applyResolvedBinding(
602
+ nextBlock,
603
+ resolvedValue,
604
+ bindingPath
605
+ );
606
+ }
420
607
  }
421
608
  }
422
609
 
@@ -447,8 +634,20 @@ const getIteratorItems = ({
447
634
  sectionDataSource,
448
635
  dataPath,
449
636
  isDesigner = false,
450
- pageContext
451
- }: BindingContext & { dataPath?: string }): Record<string, unknown>[] => {
637
+ pageContext,
638
+ scopedItems
639
+ }: BindingContext & {
640
+ dataPath?: string;
641
+ scopedItems?: Record<string, unknown>[] | null;
642
+ }): Record<string, unknown>[] => {
643
+ // A nested repeat draws the list the row it lives in handed it. Nothing else
644
+ // is redirected: a top-level iterator has no row, and a nested one pointing
645
+ // at a section path is handed nothing and keeps resolving exactly where it
646
+ // did before.
647
+ if (scopedItems) {
648
+ return scopedItems;
649
+ }
650
+
452
651
  const bindingRoot = getSectionBindingRoot({
453
652
  sectionDataSource,
454
653
  pageContext,
@@ -488,10 +687,279 @@ const getIteratorItems = ({
488
687
  return [];
489
688
  };
490
689
 
690
+ /**
691
+ * Per-row appearance.
692
+ *
693
+ * Every clone is pinned to the template's style identity, so the generated
694
+ * stylesheet writes one rule that serves every row. That is deliberate — it is
695
+ * what keeps a repeated card cheap and what makes a style edit apply to the
696
+ * whole list. But a badge whose colour comes from the product itself cannot be
697
+ * built that way: the value differs per row.
698
+ *
699
+ * A style value may therefore carry a binding (`{{item.badgeColor}}`). Only the
700
+ * properties that actually carry one are resolved per clone; every other
701
+ * property is left on the shared rule, untouched and byte-identical. A bound
702
+ * property that does not resolve is dropped rather than emitted, so it stays
703
+ * unset instead of becoming `background-color: undefined`.
704
+ */
705
+
706
+ /**
707
+ * What a resolved style value is allowed to contain.
708
+ *
709
+ * This value comes from merchant data — an attribute a shop owner typed into
710
+ * the catalogue — and ends up inside a CSS declaration, so it is untrusted.
711
+ * Only what a colour, a length, a number or a simple function needs is kept:
712
+ * letters, digits, space and `# % , . / + - _ ( )`. Everything else is
713
+ * rejected, which is what stops:
714
+ * `;` `}` `{` closing this declaration/rule and opening another one,
715
+ * `<` `>` the `</style` sequence that ends the raw-text element,
716
+ * `"` `'` `\` string context and CSS escapes (`\3c` re-creates `<`),
717
+ * `:` `!` `@` a second property, an `!important` hijack, an at-rule,
718
+ * `*` the `/*` comment that would swallow the rest of the rule.
719
+ * Asset-fetching functions are rejected on top of that: their characters are
720
+ * all legal, but they would let catalogue data pull a remote request (a
721
+ * tracking pixel) into every card on the page.
722
+ */
723
+ const SAFE_BOUND_CSS_VALUE = /^[A-Za-z0-9 #%,./+\-_()]+$/;
724
+ const FETCHING_CSS_FUNCTION =
725
+ /(?:url|image-set|image|cross-fade|element|expression|attr)\s*\(/i;
726
+ const MAX_BOUND_CSS_VALUE_LENGTH = 120;
727
+
728
+ const sanitizeBoundCssValue = (value: string): string | undefined => {
729
+ const trimmed = value.trim();
730
+
731
+ if (!trimmed || trimmed.length > MAX_BOUND_CSS_VALUE_LENGTH) {
732
+ return undefined;
733
+ }
734
+
735
+ if (!SAFE_BOUND_CSS_VALUE.test(trimmed)) return undefined;
736
+ if (FETCHING_CSS_FUNCTION.test(trimmed)) return undefined;
737
+
738
+ return trimmed;
739
+ };
740
+
741
+ /** Only text and finite numbers make a CSS value; anything else is "no value". */
742
+ const toCssText = (value: unknown): string | undefined => {
743
+ if (typeof value === 'string') return value;
744
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
745
+ return undefined;
746
+ };
747
+
748
+ const resolveBoundStyleValue = (
749
+ value: string,
750
+ itemData: unknown,
751
+ styleKey: string
752
+ ): string | undefined => {
753
+ let hasUnresolvedToken = false;
754
+
755
+ const text = value.replace(
756
+ BINDING_TOKEN_PATTERN,
757
+ (_match, rawPath: string) => {
758
+ const path = String(rawPath).trim();
759
+ // Only `item.*` addresses the row, the same rule a text binding follows.
760
+ // Stripping the prefix and reading the row regardless would let
761
+ // `{{brandColor}}` — a site-level spelling — be answered by whatever
762
+ // catalogue field happens to share the name, so a colour meant to be
763
+ // the same everywhere would change per product.
764
+ const resolved = isRowScopedToken(path)
765
+ ? getValueAtPath(itemData, stripItemPrefix(path))
766
+ : undefined;
767
+ const asText = toCssText(resolved);
768
+
769
+ if (asText === undefined) {
770
+ hasUnresolvedToken = true;
771
+ return '';
772
+ }
773
+
774
+ return asText;
775
+ }
776
+ );
777
+
778
+ // Half a CSS value ("rgb(255, , 0)") is worse than none: it can change what
779
+ // the shorthand around it means. All or nothing.
780
+ if (hasUnresolvedToken) return undefined;
781
+
782
+ const safe = sanitizeBoundCssValue(text);
783
+ if (safe === undefined) {
784
+ devWarn(
785
+ 'iterator',
786
+ `bound style "${styleKey}" resolved to a value CSS cannot safely carry; leaving the property unset`
787
+ );
788
+ }
789
+
790
+ return safe;
791
+ };
792
+
793
+ /**
794
+ * Whether this row is the one that answers a style value: it carries a token
795
+ * and at least one of its tokens addresses the row. A value spelled entirely
796
+ * in site-level tokens is left exactly as authored — not this repeat's to
797
+ * resolve, and the CSS layer already leaves an unresolved token unset.
798
+ */
799
+ const isRowBoundStyleValue = (value: unknown): value is string =>
800
+ hasBindingToken(value) && isRowScopedBinding(value as string);
801
+
802
+ /**
803
+ * Resolves the bound values in one block's styles against a row. Returns the
804
+ * SAME object when nothing is bound, so a composition without style bindings
805
+ * is not merely equal but identical — no new object, no changed CSS.
806
+ */
807
+ const resolveStyleBindingsForRow = (
808
+ styles: unknown,
809
+ itemData: unknown
810
+ ): unknown => {
811
+ if (!isRecord(styles)) return styles;
812
+
813
+ let changed = false;
814
+ const nextStyles: Record<string, unknown> = {};
815
+
816
+ Object.entries(styles).forEach(([key, value]) => {
817
+ if (typeof value === 'string') {
818
+ if (!isRowBoundStyleValue(value)) {
819
+ nextStyles[key] = value;
820
+ return;
821
+ }
822
+
823
+ changed = true;
824
+ const resolved = resolveBoundStyleValue(value, itemData, key);
825
+ if (resolved !== undefined) {
826
+ nextStyles[key] = resolved;
827
+ }
828
+ return;
829
+ }
830
+
831
+ // Responsive shape: one value per breakpoint, each bindable on its own.
832
+ if (isRecord(value)) {
833
+ const isBoundHere = Object.values(value).some(isRowBoundStyleValue);
834
+
835
+ if (!isBoundHere) {
836
+ nextStyles[key] = value;
837
+ return;
838
+ }
839
+
840
+ changed = true;
841
+ let hasUnresolvedBreakpoint = false;
842
+ const nextValue: Record<string, unknown> = {};
843
+
844
+ Object.entries(value).forEach(([breakpoint, breakpointValue]) => {
845
+ if (!hasBindingToken(breakpointValue)) {
846
+ nextValue[breakpoint] = breakpointValue;
847
+ return;
848
+ }
849
+
850
+ const resolved = isRowBoundStyleValue(breakpointValue)
851
+ ? resolveBoundStyleValue(breakpointValue, itemData, key)
852
+ : undefined;
853
+
854
+ if (resolved === undefined) {
855
+ hasUnresolvedBreakpoint = true;
856
+ return;
857
+ }
858
+
859
+ nextValue[breakpoint] = resolved;
860
+ });
861
+
862
+ // A breakpoint the row does not answer is unset AT EVERY breakpoint, so
863
+ // the property falls back to the shared rule. Keeping the breakpoints
864
+ // that did resolve would be worse than useless: a missing breakpoint is
865
+ // served the desktop value, so a mobile binding that resolved to nothing
866
+ // would be painted with the desktop row's colour rather than left alone.
867
+ if (!hasUnresolvedBreakpoint) {
868
+ nextStyles[key] = nextValue;
869
+ }
870
+ return;
871
+ }
872
+
873
+ nextStyles[key] = value;
874
+ });
875
+
876
+ return changed ? nextStyles : styles;
877
+ };
878
+
879
+ /**
880
+ * The list a nested iterator draws. Written onto the nested iterator's
881
+ * properties while its parent row is being built, and read back when that
882
+ * iterator expands — so the tree still has exactly one expansion point and no
883
+ * caller has to learn a new argument.
884
+ *
885
+ * The LIST, not the row it came from: whatever is written here rides along in
886
+ * the rendered tree until that iterator expands and is serialized to the
887
+ * client with it, so handing over the whole row would ship every field the API
888
+ * returned — cost prices, stock notes, internal codes — for a strip that draws
889
+ * three colours. A path that resolves to no list is not written at all.
890
+ */
891
+ const SCOPED_ITEMS_PROPERTY = 'iteratorScopedItems';
892
+
893
+ const readIteratorDataPath = (block: Block): string => {
894
+ const direct =
895
+ typeof block.iteratorDataPath === 'string' ? block.iteratorDataPath : '';
896
+ if (direct.trim()) return direct.trim();
897
+
898
+ const fromProperties =
899
+ typeof block.properties?.iteratorDataPath === 'string'
900
+ ? block.properties.iteratorDataPath
901
+ : '';
902
+
903
+ return fromProperties.trim();
904
+ };
905
+
906
+ const readScopedItems = (
907
+ properties: unknown
908
+ ): Record<string, unknown>[] | null => {
909
+ if (!isRecord(properties)) return null;
910
+
911
+ const scopedItems = properties[SCOPED_ITEMS_PROPERTY];
912
+ return Array.isArray(scopedItems)
913
+ ? (scopedItems as Record<string, unknown>[])
914
+ : null;
915
+ };
916
+
917
+ /** Returns the same object when there is no handed-over list to forget. */
918
+ const forgetScopedItems = (properties: unknown): unknown => {
919
+ if (!isRecord(properties) || !(SCOPED_ITEMS_PROPERTY in properties)) {
920
+ return properties;
921
+ }
922
+
923
+ const { [SCOPED_ITEMS_PROPERTY]: _consumedItems, ...rest } = properties;
924
+ return rest;
925
+ };
926
+
927
+ /**
928
+ * A row with nothing behind it: the canvas draws these so a designer has
929
+ * something to arrange before any data exists.
930
+ *
931
+ * The test is "an object with no keys", not "no keys", because an entry of a
932
+ * list of plain values has none to count — `Object.keys(38)` is empty, and
933
+ * reading a real size as a blank canvas row prints the authored fallback.
934
+ */
935
+ const isPlaceholderRow = (itemData: unknown): boolean => {
936
+ if (itemData === null || itemData === undefined) return true;
937
+ if (typeof itemData !== 'object') return false;
938
+ return Object.keys(itemData).length === 0;
939
+ };
940
+
491
941
  const replaceBlockValues = (
492
942
  blockToReplace: Block,
493
- itemData: Record<string, unknown>,
494
- itemIndex: number
943
+ itemData: unknown,
944
+ itemIndex: number,
945
+ /**
946
+ * True once the walk has stepped inside a repeat of its own. Everything
947
+ * below that point is drawn once per entry of THAT repeat, so this row is
948
+ * not the one its appearance is resolved against.
949
+ *
950
+ * Nothing about this row is read below that point — not its styles, not its
951
+ * text, not its visibility rule.
952
+ *
953
+ * A style token is consumed the moment it resolves, so the outer row's
954
+ * reading of `{{item.hex}}` would be final. A text binding looks safer,
955
+ * because the block keeps its `dataBinding` and the owning repeat resolves it
956
+ * again — but only when the inner entry ANSWERS the path. When it does not,
957
+ * the outer row's value has nothing to overwrite it and stays: a swatch bound
958
+ * to `item.internal_note` prints the product's warehouse note in every
959
+ * swatch. A visibility rule is worse still: `hidden` is only ever set, never
960
+ * taken back, so one outer row decides every entry at once.
961
+ */
962
+ insideNestedRepeat = false
495
963
  ): Block => {
496
964
  const nextBlock: Block = {
497
965
  ...blockToReplace,
@@ -500,31 +968,144 @@ const replaceBlockValues = (
500
968
  : blockToReplace.properties
501
969
  };
502
970
 
503
- const isPlaceholderMode = !itemData || Object.keys(itemData).length === 0;
504
- const bindingPath =
971
+ const isPlaceholderMode = isPlaceholderRow(itemData);
972
+ // The children of a repeat belong to that repeat, not to this row. Read off
973
+ // this block rather than the parent so the rule holds at any depth: a repeat
974
+ // inside a repeat inside a repeat resolves its own template and no other.
975
+ const childInsideNestedRepeat =
976
+ insideNestedRepeat || nextBlock.isIterator === true;
977
+
978
+ // A badge that exists on three products out of forty, a campaign label on
979
+ // two: the row is the same for every item, so the element has to be able to
980
+ // remove itself. Written onto `hidden`, which the storefront already
981
+ // respects.
982
+ //
983
+ // Never in placeholder mode: with no data every field reads empty, so the
984
+ // rule would hide the element on the canvas and leave the designer with
985
+ // nothing to arrange.
986
+ if (!isPlaceholderMode) {
987
+ // All of this is this row's business only while the walk is still inside
988
+ // the repeat that owns this row.
989
+ if (!insideNestedRepeat) {
990
+ const visibility = readRowVisibility(nextBlock.properties);
991
+ if (visibility && !isVisibleForItem(visibility, itemData)) {
992
+ nextBlock.hidden = true;
993
+ }
994
+
995
+ // Only the bound properties are replaced; the rest of the block's styles
996
+ // stay on the shared rule, so a card with one bound colour keeps the
997
+ // whole stylesheet it had.
998
+ const resolvedStyles = resolveStyleBindingsForRow(
999
+ nextBlock.styles,
1000
+ itemData
1001
+ );
1002
+ if (resolvedStyles !== nextBlock.styles) {
1003
+ nextBlock.styles = resolvedStyles;
1004
+ }
1005
+
1006
+ // A repeat inside a row means "the row's list": a colour-swatch strip
1007
+ // iterates `item.colors` of THIS product. Resolved against the section's
1008
+ // data source that path is nothing, which is why such a strip renders a
1009
+ // single placeholder today. The list is handed to the nested iterator
1010
+ // here and read back when it expands.
1011
+ const nestedPath =
1012
+ nextBlock.isIterator === true
1013
+ ? normalizeBindingExpression(readIteratorDataPath(nextBlock))
1014
+ : '';
1015
+ if (nestedPath && isRowScopedBinding(nestedPath)) {
1016
+ const nestedItems = getValueAtPath(
1017
+ itemData,
1018
+ stripItemPrefix(nestedPath)
1019
+ );
1020
+
1021
+ // A defined-but-non-array answer means the path names the wrong shape
1022
+ // (`item.colour`, a string, where `item.colours` was meant). Because
1023
+ // the handover makes such a repeat draw nothing at all, staying quiet
1024
+ // would leave the author with an empty strip and no clue why — while
1025
+ // the section-scoped spelling of the same typo has always been
1026
+ // reported. An absent field stays silent: it is ambiguous with data
1027
+ // that has not landed yet.
1028
+ if (nestedItems !== undefined && !Array.isArray(nestedItems)) {
1029
+ devWarn(
1030
+ 'iterator',
1031
+ `iteratorDataPath "${nestedPath}" resolved to a non-array value on the row; rendering no items`
1032
+ );
1033
+ }
1034
+
1035
+ // Written even when the row answers with nothing. An empty handover is
1036
+ // the row saying "this product has no colours", which is a different
1037
+ // statement from a repeat that was never given a list and still owes
1038
+ // the canvas a placeholder row — and the difference is what keeps an
1039
+ // empty swatch from appearing next to products that do have colours.
1040
+ nextBlock.properties = {
1041
+ ...(nextBlock.properties || {}),
1042
+ [SCOPED_ITEMS_PROPERTY]: Array.isArray(nestedItems) ? nestedItems : []
1043
+ };
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ const bindingPath = normalizeBindingExpression(
505
1049
  typeof nextBlock.properties?.dataBinding === 'string'
506
1050
  ? nextBlock.properties.dataBinding.trim()
507
- : '';
1051
+ : ''
1052
+ );
508
1053
 
509
- if (bindingPath && !isPlaceholderMode) {
510
- const itemBindingPath = bindingPath.replace(/^item\./, '');
511
- const resolvedValue = getValueAtPath(itemData, itemBindingPath);
1054
+ // `!insideNestedRepeat` for the same reason the styles carry it: the repeat
1055
+ // that owns a binding is the one that reads it. Leaving the binding
1056
+ // unresolved here costs nothing — the owning repeat resolves it against its
1057
+ // own entry — while resolving it writes a value the inner entry may have no
1058
+ // way to replace.
1059
+ if (bindingPath && !isPlaceholderMode && !insideNestedRepeat) {
1060
+ if (hasBindingToken(bindingPath)) {
1061
+ const target = getItemBindingTarget(nextBlock);
1062
+ const interpolated = interpolateBindingTemplate(
1063
+ bindingPath,
1064
+ (path) => getValueAtPath(itemData, stripItemPrefix(path)),
1065
+ (value) => normalizeScalarValue(value, target, nextBlock.type)
1066
+ );
512
1067
 
513
- if (resolvedValue !== undefined) {
514
- if (
515
- nextBlock.type === 'text' &&
516
- bindingPath.startsWith('item.') &&
517
- isPriceBinding(bindingPath)
518
- ) {
519
- nextBlock.value = buildProductPriceHtml(itemData);
520
- } else {
1068
+ if (interpolated !== undefined) {
521
1069
  return {
522
- ...applyResolvedBinding(nextBlock, resolvedValue, bindingPath),
1070
+ ...applyResolvedBinding(nextBlock, interpolated, bindingPath),
523
1071
  blocks: nextBlock.blocks?.map((childBlock) =>
524
- replaceBlockValues(childBlock, itemData, itemIndex)
1072
+ replaceBlockValues(
1073
+ childBlock,
1074
+ itemData,
1075
+ itemIndex,
1076
+ childInsideNestedRepeat
1077
+ )
525
1078
  )
526
1079
  };
527
1080
  }
1081
+ } else {
1082
+ const itemBindingPath = stripItemPrefix(bindingPath);
1083
+ const resolvedValue = getValueAtPath(itemData, itemBindingPath);
1084
+
1085
+ if (resolvedValue !== undefined) {
1086
+ if (
1087
+ nextBlock.type === 'text' &&
1088
+ bindingPath.startsWith('item.') &&
1089
+ isPriceBinding(bindingPath) &&
1090
+ // A price is read off the whole row (list price, currency, discount),
1091
+ // so a row that is a plain value cannot answer one.
1092
+ isRecord(itemData)
1093
+ ) {
1094
+ nextBlock.value = buildProductPriceHtml(itemData);
1095
+ } else {
1096
+ return {
1097
+ ...applyResolvedBinding(nextBlock, resolvedValue, bindingPath),
1098
+ blocks: nextBlock.blocks?.map((childBlock) =>
1099
+ replaceBlockValues(
1100
+ childBlock,
1101
+ itemData,
1102
+ itemIndex,
1103
+ childInsideNestedRepeat
1104
+ )
1105
+ )
1106
+ };
1107
+ }
1108
+ }
528
1109
  }
529
1110
  } else if (isPlaceholderMode && nextBlock.type === 'image') {
530
1111
  const placeholderImages = [
@@ -547,7 +1128,12 @@ const replaceBlockValues = (
547
1128
 
548
1129
  if (nextBlock.blocks && nextBlock.blocks.length > 0) {
549
1130
  nextBlock.blocks = nextBlock.blocks.map((childBlock) =>
550
- replaceBlockValues(childBlock, itemData, itemIndex)
1131
+ replaceBlockValues(
1132
+ childBlock,
1133
+ itemData,
1134
+ itemIndex,
1135
+ childInsideNestedRepeat
1136
+ )
551
1137
  );
552
1138
  }
553
1139
 
@@ -560,7 +1146,8 @@ export const buildIteratorBlock = ({
560
1146
  pageContext,
561
1147
  isDesigner = false,
562
1148
  forceIteratorCount,
563
- forceIteratorOffset
1149
+ forceIteratorOffset,
1150
+ items: providedItems
564
1151
  }: IteratorOptions): Block => {
565
1152
  if (
566
1153
  !iteratorBlock.isIterator ||
@@ -574,43 +1161,59 @@ export const buildIteratorBlock = ({
574
1161
  const dataPath =
575
1162
  iteratorBlock.iteratorDataPath ||
576
1163
  iteratorBlock.properties?.iteratorDataPath;
577
- const items = getIteratorItems({
578
- sectionDataSource,
579
- pageContext,
580
- dataPath,
581
- isDesigner
582
- });
583
-
584
- const useIteratorCount =
585
- forceIteratorCount !== undefined
586
- ? true
587
- : iteratorBlock.properties?.useIteratorCount === true ||
588
- iteratorBlock.properties?.useIteratorCount === 'true';
589
- const iteratorCount =
590
- forceIteratorCount !== undefined
591
- ? Math.max(1, Math.floor(forceIteratorCount))
592
- : parsePositiveNumber(iteratorBlock.properties?.iteratorCount, 1);
593
- const iteratorOffset =
594
- forceIteratorOffset !== undefined
595
- ? Math.max(0, Math.floor(forceIteratorOffset))
596
- : parsePositiveNumber(iteratorBlock.properties?.iteratorOffset, 0);
1164
+ // Only a repeat that resolves its own list can have been handed one; a
1165
+ // caller that brought its items (a functional block's basket rows, orders,
1166
+ // stores) speaks for itself.
1167
+ const scopedItems =
1168
+ providedItems === undefined
1169
+ ? readScopedItems(iteratorBlock.properties)
1170
+ : null;
1171
+ const items =
1172
+ providedItems ??
1173
+ getIteratorItems({
1174
+ sectionDataSource,
1175
+ pageContext,
1176
+ dataPath,
1177
+ isDesigner,
1178
+ scopedItems
1179
+ });
597
1180
 
598
- const displayItems =
599
- items.length > 0
600
- ? useIteratorCount
601
- ? items.slice(iteratorOffset, iteratorOffset + iteratorCount)
602
- : items
603
- : [];
1181
+ const { count: iteratorCount, items: displayItems } = sliceIteratorItems(
1182
+ iteratorBlock,
1183
+ items,
1184
+ { forceIteratorCount, forceIteratorOffset }
1185
+ );
604
1186
 
1187
+ // The count floor draws a row where there is no data, which is what the
1188
+ // canvas and a page whose fetch has not landed both need. A repeat handed
1189
+ // its row's list is neither: the row has already answered, and an empty
1190
+ // answer means this product has no colours — one empty swatch next to
1191
+ // products that do have colours reads as a colour the shopper can pick.
605
1192
  const actualCount =
606
- displayItems.length > 0 ? displayItems.length : Math.max(iteratorCount, 1);
1193
+ displayItems.length > 0
1194
+ ? displayItems.length
1195
+ : scopedItems
1196
+ ? 0
1197
+ : Math.max(iteratorCount, 1);
607
1198
 
608
1199
  const clonedBlocks = Array.from({ length: actualCount }, (_, index) => {
609
- const item = displayItems[index] || {};
1200
+ // `??`, not `||`: an entry of a list of plain values can be falsy (0, '')
1201
+ // and is still the row, while a missing entry is the placeholder padding
1202
+ // the count floor asked for.
1203
+ const item = displayItems[index] ?? {};
610
1204
 
611
1205
  const templateCopy: Block = {
612
1206
  ...template,
613
1207
  id: `${template.id}-clone-${index}`,
1208
+ // The clone needs its own id (React keys, selection), but the generated
1209
+ // stylesheet only knows the template's id and the DOM writes
1210
+ // `styleSourceId || id`. Without this, every style authored on a
1211
+ // repeated item is emitted and then matches nothing. It stays pinned
1212
+ // even when the row varies its appearance: giving a clone its own style
1213
+ // identity would drop the shared rule for EVERY property, so a bound
1214
+ // property is resolved onto the clone's own style set instead and only
1215
+ // that property stops being shared.
1216
+ styleSourceId: template.styleSourceId || template.id,
614
1217
  properties: template.properties
615
1218
  ? { ...template.properties }
616
1219
  : template.properties,
@@ -622,8 +1225,18 @@ export const buildIteratorBlock = ({
622
1225
  return replaceBlockValues(templateCopy, item, index);
623
1226
  });
624
1227
 
1228
+ const nextProperties = forgetScopedItems(iteratorBlock.properties);
1229
+
625
1230
  return {
626
1231
  ...iteratorBlock,
1232
+ // The list a nested iterator was handed has now been consumed, so it is
1233
+ // dropped again rather than riding along in the rendered tree. Written
1234
+ // only when there was something to drop: writing it unconditionally gives
1235
+ // an iterator that never had properties a `properties: undefined` key, and
1236
+ // an object that gained a key is no longer the object it was.
1237
+ ...(nextProperties !== iteratorBlock.properties
1238
+ ? { properties: nextProperties }
1239
+ : {}),
627
1240
  blocks: clonedBlocks
628
1241
  };
629
1242
  };