@akinon/pz-theme 2.0.39-rc.0 → 2.0.39

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.
@@ -2,6 +2,7 @@
2
2
  // a value import of the block module would close that loop at runtime.
3
3
  import type { Block } from '../theme-block';
4
4
  import { devWarn } from './dev-warn';
5
+ import { isMoneyPath } from './field-semantics';
5
6
  import { isVisibleForItem, readRowVisibility } from './row-visibility';
6
7
 
7
8
  interface BindingContext {
@@ -14,6 +15,19 @@ interface BindingContext {
14
15
  * running in en-GB got Turkish grouping in every repeated row.
15
16
  */
16
17
  locale?: string;
18
+ /**
19
+ * Storefront currency, the last resort when a bound money value carries no
20
+ * currency of its own. Commerce publishes a currency per basket line, so an
21
+ * empty basket has none to read.
22
+ */
23
+ currency?: string;
24
+ /**
25
+ * Lets the page context stand in as the binding root when the section has no
26
+ * data source of its own. Opt-in rather than automatic: the same root feeds
27
+ * the iterator lookup, and a repeat with no data path would otherwise start
28
+ * reading `items` / `products` off the page context.
29
+ */
30
+ allowPageContextFallback?: boolean;
17
31
  }
18
32
 
19
33
  interface IteratorOptions extends BindingContext {
@@ -218,7 +232,8 @@ const getStaticBindingRoot = (
218
232
  const getSectionBindingRoot = ({
219
233
  sectionDataSource,
220
234
  pageContext,
221
- isDesigner = false
235
+ isDesigner = false,
236
+ allowPageContextFallback = false
222
237
  }: BindingContext): Record<string, unknown> | null => {
223
238
  if (
224
239
  sectionDataSource?.type === PAGE_CONTEXT_DATA_SOURCE_TYPE &&
@@ -240,6 +255,14 @@ const getSectionBindingRoot = ({
240
255
  return staticRoot;
241
256
  }
242
257
 
258
+ // The page's own commerce data is the root a designer means when they bind
259
+ // `basket.totalAmount` on the basket page. Creating a data source first is
260
+ // the step nobody discovers, so a section without one falls back to it —
261
+ // for value bindings only, never for the iterator lookup.
262
+ if (allowPageContextFallback && isRecord(pageContext)) {
263
+ return pageContext;
264
+ }
265
+
243
266
  return null;
244
267
  };
245
268
 
@@ -483,10 +506,52 @@ const isPriceBinding = (dataBinding: string): boolean => {
483
506
  );
484
507
  };
485
508
 
509
+ /**
510
+ * A bound total used to print "799.90" beside a built-in block printing
511
+ * "1.299,90 TL", because formatting was reached only from the row-scoped
512
+ * product path. Which fields are money is decided in one place — the same one
513
+ * the editor labels its picker from, so the two cannot disagree.
514
+ */
515
+ const isPageMoneyBinding = (bindingPath: string): boolean =>
516
+ Boolean(bindingPath) &&
517
+ !bindingPath.startsWith('item.') &&
518
+ isMoneyPath(bindingPath);
519
+
520
+ /**
521
+ * The currency that belongs to the bound value: the one its own root publishes
522
+ * (`basket.currency`), then the first row that carries one (an empty basket
523
+ * has no rows), then the storefront's.
524
+ */
525
+ const readContextCurrency = (
526
+ bindingRoot: Record<string, unknown> | null,
527
+ bindingPath: string,
528
+ fallback?: string
529
+ ): string | undefined => {
530
+ const rootKey = bindingPath.split('.')[0];
531
+ const scopeValue = isRecord(bindingRoot) ? bindingRoot[rootKey] : undefined;
532
+ const scope = isRecord(scopeValue) ? scopeValue : null;
533
+
534
+ if (scope) {
535
+ const own = scope.currency;
536
+ if (typeof own === 'string' && own.trim()) return own.trim();
537
+
538
+ const rows = Array.isArray(scope.items) ? scope.items : [];
539
+ for (const row of rows) {
540
+ const rowCurrency = isRecord(row) ? row.currency : undefined;
541
+ if (typeof rowCurrency === 'string' && rowCurrency.trim()) {
542
+ return rowCurrency.trim();
543
+ }
544
+ }
545
+ }
546
+
547
+ return fallback && fallback.trim() ? fallback.trim() : undefined;
548
+ };
549
+
486
550
  const applyResolvedBinding = (
487
551
  block: Block,
488
552
  resolvedValue: unknown,
489
- bindingPath: string
553
+ bindingPath: string,
554
+ moneyCurrency?: string
490
555
  ): Block => {
491
556
  const target = getItemBindingTarget(block);
492
557
  const nextBlock: Block = {
@@ -505,6 +570,17 @@ const applyResolvedBinding = (
505
570
  return nextBlock;
506
571
  }
507
572
 
573
+ if (
574
+ target === 'value' &&
575
+ isPageMoneyBinding(bindingPath) &&
576
+ (typeof resolvedValue === 'string' || typeof resolvedValue === 'number')
577
+ ) {
578
+ nextBlock.value = escapeHtml(
579
+ getPriceWithCurrency(resolvedValue, moneyCurrency)
580
+ );
581
+ return nextBlock;
582
+ }
583
+
508
584
  const normalizedValue = normalizeScalarValue(
509
585
  resolvedValue,
510
586
  target,
@@ -553,12 +629,17 @@ export const resolveBlockBindings = ({
553
629
  block,
554
630
  sectionDataSource,
555
631
  pageContext,
556
- isDesigner = false
632
+ isDesigner = false,
633
+ currency
557
634
  }: BindingContext & { block: Block }): Block => {
558
635
  const bindingRoot = getSectionBindingRoot({
559
636
  sectionDataSource,
560
637
  pageContext,
561
- isDesigner
638
+ isDesigner,
639
+ // Value bindings resolve against the page context when the section has no
640
+ // data source. The iterator lookup deliberately does not — see
641
+ // getSectionBindingRoot.
642
+ allowPageContextFallback: true
562
643
  });
563
644
 
564
645
  const walk = (sourceBlock: Block): Block => {
@@ -592,7 +673,8 @@ export const resolveBlockBindings = ({
592
673
  resolvedBlock = applyResolvedBinding(
593
674
  nextBlock,
594
675
  interpolated,
595
- bindingPath
676
+ bindingPath,
677
+ readContextCurrency(bindingRoot, bindingPath, currency)
596
678
  );
597
679
  }
598
680
  } else {
@@ -601,7 +683,8 @@ export const resolveBlockBindings = ({
601
683
  resolvedBlock = applyResolvedBinding(
602
684
  nextBlock,
603
685
  resolvedValue,
604
- bindingPath
686
+ bindingPath,
687
+ readContextCurrency(bindingRoot, bindingPath, currency)
605
688
  );
606
689
  }
607
690
  }
@@ -0,0 +1,136 @@
1
+ import { buildIteratorBlock, resolveBlockBindings } from './iterator-utils';
2
+
3
+ /**
4
+ * The page a designer is editing is itself a data source: on the basket page
5
+ * `basket.totalAmount` must resolve without the designer creating a data
6
+ * source first, and it must read as money rather than as a raw API string.
7
+ */
8
+
9
+ const textBlock = (props: Record<string, unknown>, id = 'b') =>
10
+ ({
11
+ id,
12
+ type: 'text',
13
+ label: id,
14
+ value: '',
15
+ properties: {},
16
+ styles: {},
17
+ order: 0,
18
+ hidden: false,
19
+ ...props
20
+ } as any);
21
+
22
+ const basketContext = (overrides: Record<string, unknown> = {}) => ({
23
+ page: { type: 'basket', title: 'Basket', path: '/basket' },
24
+ basket: {
25
+ isEmpty: false,
26
+ itemCount: 1,
27
+ totalQuantity: 2,
28
+ totalAmount: '1299.90',
29
+ totalDiscountAmount: '100.00',
30
+ voucherCode: 'WELCOME',
31
+ currency: 'TRY',
32
+ items: [{ title: 'Spring Sneaker', currency: 'TRY', totalAmount: '649.95' }],
33
+ ...overrides
34
+ }
35
+ });
36
+
37
+ describe('page context as an implicit binding root', () => {
38
+ it('resolves a basket binding on a section that has NO data source', () => {
39
+ const result = resolveBlockBindings({
40
+ block: textBlock({
41
+ value: 'Total',
42
+ properties: { dataBinding: 'basket.totalAmount' }
43
+ }),
44
+ pageContext: basketContext()
45
+ });
46
+
47
+ expect(result.value).toBe('1.299,90 TL');
48
+ });
49
+
50
+ it('keeps the authored value when the path is not on the page', () => {
51
+ const result = resolveBlockBindings({
52
+ block: textBlock({
53
+ value: 'Fallback',
54
+ properties: { dataBinding: 'basket.shippingAmount' }
55
+ }),
56
+ pageContext: basketContext()
57
+ });
58
+
59
+ expect(result.value).toBe('Fallback');
60
+ });
61
+
62
+ it('resolves a token template mixed with literal text', () => {
63
+ const result = resolveBlockBindings({
64
+ block: textBlock({
65
+ value: '',
66
+ properties: {
67
+ dataBinding: '{{basket.totalQuantity}} items, {{basket.voucherCode}}'
68
+ }
69
+ }),
70
+ pageContext: basketContext()
71
+ });
72
+
73
+ expect(result.value).toBe('2 items, WELCOME');
74
+ });
75
+
76
+ it('does not let a source-less iterator read the page context', () => {
77
+ // The fallback is opt-in for value bindings only. A repeat with no data
78
+ // path must not start iterating whatever the page happens to publish.
79
+ const iterator = buildIteratorBlock({
80
+ iteratorBlock: {
81
+ id: 'group',
82
+ type: 'group',
83
+ label: 'Rows',
84
+ value: undefined,
85
+ properties: {},
86
+ styles: {},
87
+ order: 0,
88
+ hidden: false,
89
+ isIterator: true,
90
+ blocks: []
91
+ } as any,
92
+ pageContext: { items: [{ title: 'A' }, { title: 'B' }] }
93
+ });
94
+
95
+ expect(iterator.blocks).toHaveLength(0);
96
+ });
97
+ });
98
+
99
+ describe('money formatting for page-scoped bindings', () => {
100
+ it('formats with the currency the basket publishes', () => {
101
+ const result = resolveBlockBindings({
102
+ block: textBlock({ properties: { dataBinding: 'basket.totalAmount' } }),
103
+ pageContext: basketContext({ currency: 'EUR', items: [] })
104
+ });
105
+
106
+ expect(result.value).toBe('1.299,90 EUR');
107
+ });
108
+
109
+ it('falls back to the storefront currency for an empty basket', () => {
110
+ const result = resolveBlockBindings({
111
+ block: textBlock({ properties: { dataBinding: 'basket.totalAmount' } }),
112
+ pageContext: basketContext({ currency: '', items: [], isEmpty: true }),
113
+ currency: 'GBP'
114
+ });
115
+
116
+ expect(result.value).toBe('1.299,90 GBP');
117
+ });
118
+
119
+ it('leaves a non-money field alone', () => {
120
+ const result = resolveBlockBindings({
121
+ block: textBlock({ properties: { dataBinding: 'basket.voucherCode' } }),
122
+ pageContext: basketContext()
123
+ });
124
+
125
+ expect(result.value).toBe('WELCOME');
126
+ });
127
+
128
+ it('formats a money field on any page root, not just the basket', () => {
129
+ const result = resolveBlockBindings({
130
+ block: textBlock({ properties: { dataBinding: 'product.price' } }),
131
+ pageContext: { product: { price: '499.5', currency: 'TRY' } }
132
+ });
133
+
134
+ expect(result.value).toBe('499,50 TL');
135
+ });
136
+ });
@@ -1,3 +1,5 @@
1
+ import { FieldSemantic, readFieldSemantic } from './field-semantics';
2
+
1
3
  type DiscoveryValueType =
2
4
  | 'string'
3
5
  | 'number'
@@ -10,6 +12,12 @@ export interface PageContextDiscoveryEntry {
10
12
  path: string;
11
13
  kind: 'scalar' | 'array';
12
14
  valueType: DiscoveryValueType;
15
+ /**
16
+ * What the field means, which is what a designer needs and what `valueType`
17
+ * cannot tell them: commerce publishes money, an image URL and a product
18
+ * title as three indistinguishable strings.
19
+ */
20
+ semantic: FieldSemantic;
13
21
  sample?: string;
14
22
  }
15
23
 
@@ -113,6 +121,7 @@ export const createPageContextDiscovery = (
113
121
  path,
114
122
  kind: 'array',
115
123
  valueType: 'array',
124
+ semantic: readFieldSemantic(path, 'array'),
116
125
  sample: formatSample(value)
117
126
  });
118
127
 
@@ -136,10 +145,13 @@ export const createPageContextDiscovery = (
136
145
  return;
137
146
  }
138
147
 
148
+ const valueType = getValueType(value);
149
+
139
150
  pushEntry({
140
151
  path,
141
152
  kind: 'scalar',
142
- valueType: getValueType(value),
153
+ valueType,
154
+ semantic: readFieldSemantic(path, valueType),
143
155
  sample: formatSample(value)
144
156
  });
145
157
  };