@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.
- package/CHANGELOG.md +8 -31
- package/package.json +4 -4
- package/src/__tests__/page-context-section-binding.test.tsx +104 -0
- package/src/chrome/__tests__/live-basket-page-context.test.tsx +109 -0
- package/src/chrome/__tests__/page-context-builders.test.ts +156 -0
- package/src/chrome/live-basket-page-context.tsx +39 -0
- package/src/chrome/live-checkout-page-context.tsx +41 -0
- package/src/chrome/live-order-page-context.tsx +65 -0
- package/src/chrome/page-context.ts +172 -2
- package/src/index.ts +19 -0
- package/src/theme-placeholder-client.tsx +4 -0
- package/src/theme-placeholder-wrapper.tsx +12 -1
- package/src/theme-section.tsx +8 -3
- package/src/utils/__tests__/generator-passthrough.test.ts +92 -0
- package/src/utils/field-semantics.test.ts +72 -0
- package/src/utils/field-semantics.ts +76 -0
- package/src/utils/iterator-utils.test.ts +3 -1
- package/src/utils/iterator-utils.ts +89 -6
- package/src/utils/page-context-binding.test.ts +136 -0
- package/src/utils/page-context-discovery.ts +13 -1
|
@@ -324,6 +324,15 @@ export const buildBasketPageContext = ({
|
|
|
324
324
|
basket: {
|
|
325
325
|
id: basket?.pk || null,
|
|
326
326
|
path: path || ROUTES.BASKET,
|
|
327
|
+
/**
|
|
328
|
+
* Commerce publishes a currency per line and none on the basket itself,
|
|
329
|
+
* so a bound total had nothing to format against. Lifted from the first
|
|
330
|
+
* line that carries one, which is the precedence the summary block uses.
|
|
331
|
+
*/
|
|
332
|
+
currency:
|
|
333
|
+
normalizedItems.find(
|
|
334
|
+
item => typeof item.currency === 'string' && item.currency
|
|
335
|
+
)?.currency || '',
|
|
327
336
|
checkoutUrl: ROUTES.CHECKOUT,
|
|
328
337
|
continueShoppingUrl: ROUTES.HOME,
|
|
329
338
|
isEmpty: normalizedItems.length === 0,
|
|
@@ -379,9 +388,170 @@ export const buildAccountPageContext = ({
|
|
|
379
388
|
lastName,
|
|
380
389
|
fullName,
|
|
381
390
|
email,
|
|
382
|
-
phone:
|
|
383
|
-
|
|
391
|
+
phone:
|
|
392
|
+
toStringValue(profileInfo?.phone) ||
|
|
393
|
+
toStringValue(sessionUser?.phone),
|
|
394
|
+
gender:
|
|
395
|
+
toStringValue(profileInfo?.gender) ||
|
|
396
|
+
toStringValue(sessionUser?.gender)
|
|
384
397
|
}
|
|
385
398
|
}
|
|
386
399
|
};
|
|
387
400
|
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The shopper's own order, for the success page and the account order detail.
|
|
404
|
+
*
|
|
405
|
+
* Deliberately a projection rather than a spread: the raw payload carries
|
|
406
|
+
* `identity_number`, `tax_no`, `iban` and `remote_addr`, none of which belongs
|
|
407
|
+
* in a template a merchandiser edits. What is published here is what a receipt
|
|
408
|
+
* legitimately shows, and it only ever travels through a client provider —
|
|
409
|
+
* never as a server prop into a cacheable page.
|
|
410
|
+
*/
|
|
411
|
+
export const buildOrderPageContext = ({
|
|
412
|
+
order,
|
|
413
|
+
path,
|
|
414
|
+
pageType = 'order-success',
|
|
415
|
+
title = 'Order'
|
|
416
|
+
}: {
|
|
417
|
+
order?: UnknownRecord | null;
|
|
418
|
+
path?: string;
|
|
419
|
+
pageType?: string;
|
|
420
|
+
title?: string;
|
|
421
|
+
}) => {
|
|
422
|
+
const source = toRecord(order);
|
|
423
|
+
const currency = toStringValue(toRecord(source.currency).value);
|
|
424
|
+
|
|
425
|
+
const items = (Array.isArray(source.orderitem_set)
|
|
426
|
+
? source.orderitem_set
|
|
427
|
+
: []
|
|
428
|
+
).map(entry => {
|
|
429
|
+
const item = toRecord(entry);
|
|
430
|
+
const product = toRecord(item.product);
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
...item,
|
|
434
|
+
title: toStringValue(product.name),
|
|
435
|
+
name: toStringValue(product.name),
|
|
436
|
+
url: toStringValue(product.absolute_url),
|
|
437
|
+
image: toStringValue(product.image),
|
|
438
|
+
price: toStringValue(item.price),
|
|
439
|
+
unitPrice: toStringValue(item.price),
|
|
440
|
+
quantity: 1,
|
|
441
|
+
status: toStringValue(toRecord(item.status).label),
|
|
442
|
+
currency
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const discounts = (Array.isArray(source.discountitem_set)
|
|
447
|
+
? source.discountitem_set
|
|
448
|
+
: []
|
|
449
|
+
).map(entry => {
|
|
450
|
+
const discount = toRecord(entry);
|
|
451
|
+
return {
|
|
452
|
+
name: toStringValue(discount.name),
|
|
453
|
+
amount: toStringValue(discount.amount),
|
|
454
|
+
currency
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const shippingAddress = toRecord(source.shipping_address);
|
|
459
|
+
const orderNumber =
|
|
460
|
+
toStringValue(source.number) || (source.id ? `#${source.id}` : '');
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
page: {
|
|
464
|
+
type: pageType,
|
|
465
|
+
title,
|
|
466
|
+
path: path || ''
|
|
467
|
+
},
|
|
468
|
+
order: {
|
|
469
|
+
id: source.id ?? null,
|
|
470
|
+
number: orderNumber,
|
|
471
|
+
status: toStringValue(toRecord(source.status).label),
|
|
472
|
+
createdDate: toStringValue(source.created_date),
|
|
473
|
+
currency,
|
|
474
|
+
totalAmount: toStringValue(source.amount),
|
|
475
|
+
shippingAmount: toStringValue(source.shipping_amount),
|
|
476
|
+
email: toStringValue(source.user_email),
|
|
477
|
+
itemCount: items.length,
|
|
478
|
+
items,
|
|
479
|
+
discounts,
|
|
480
|
+
shippingAddress: {
|
|
481
|
+
fullName: [
|
|
482
|
+
toStringValue(shippingAddress.first_name),
|
|
483
|
+
toStringValue(shippingAddress.last_name)
|
|
484
|
+
]
|
|
485
|
+
.filter(Boolean)
|
|
486
|
+
.join(' '),
|
|
487
|
+
line: toStringValue(shippingAddress.line),
|
|
488
|
+
district: toStringValue(shippingAddress.district),
|
|
489
|
+
city: toStringValue(shippingAddress.city),
|
|
490
|
+
country: toStringValue(shippingAddress.country)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The checkout zones above and below the code-owned flow. The basket root is
|
|
498
|
+
* republished unchanged so a composition written for the basket page keeps
|
|
499
|
+
* working here, and `checkout-summary-block` already reads it.
|
|
500
|
+
*/
|
|
501
|
+
export const buildCheckoutPageContext = ({
|
|
502
|
+
basket,
|
|
503
|
+
preOrder,
|
|
504
|
+
path
|
|
505
|
+
}: {
|
|
506
|
+
basket?: Basket | null;
|
|
507
|
+
preOrder?: UnknownRecord | null;
|
|
508
|
+
path?: string;
|
|
509
|
+
}) => {
|
|
510
|
+
const basketContext = buildBasketPageContext({ basket });
|
|
511
|
+
const source = toRecord(preOrder);
|
|
512
|
+
const shippingOption = toRecord(source.shipping_option);
|
|
513
|
+
const currency =
|
|
514
|
+
toStringValue(toRecord(source.currency).value) ||
|
|
515
|
+
toStringValue(basketContext.basket.currency);
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
...basketContext,
|
|
519
|
+
page: {
|
|
520
|
+
type: 'checkout',
|
|
521
|
+
title: 'Checkout',
|
|
522
|
+
path: path || ROUTES.CHECKOUT
|
|
523
|
+
},
|
|
524
|
+
checkout: {
|
|
525
|
+
path: path || ROUTES.CHECKOUT,
|
|
526
|
+
currency,
|
|
527
|
+
totalAmount: toStringValue(source.amount),
|
|
528
|
+
shippingAmount: toStringValue(source.shipping_amount),
|
|
529
|
+
unpaidAmount: toStringValue(source.unpaid_amount),
|
|
530
|
+
shippingOption: toStringValue(shippingOption.name),
|
|
531
|
+
hasShippingOption: Boolean(shippingOption.pk),
|
|
532
|
+
stepCount: Number(source.step_count) || 0
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/** A CMS flat page: no commerce entity, but a title and a slug to bind. */
|
|
538
|
+
export const buildFlatPageContext = (flatPage?: UnknownRecord | null) => {
|
|
539
|
+
const source = toRecord(flatPage);
|
|
540
|
+
const title = toStringValue(source.title);
|
|
541
|
+
const slug = toStringValue(source.slug);
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
page: {
|
|
545
|
+
type: 'flat-page',
|
|
546
|
+
title: title || 'Page',
|
|
547
|
+
slug,
|
|
548
|
+
path: toStringValue(source.absolute_url) || (slug ? `/${slug}` : '')
|
|
549
|
+
},
|
|
550
|
+
flatPage: {
|
|
551
|
+
title,
|
|
552
|
+
slug,
|
|
553
|
+
content: toStringValue(source.content),
|
|
554
|
+
url: toStringValue(source.absolute_url)
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,25 @@ export type { Block } from './theme-block';
|
|
|
22
22
|
|
|
23
23
|
export { default as ChromeSlot } from './chrome/chrome-slot';
|
|
24
24
|
export { ChromeFallback } from './chrome/chrome-fallback';
|
|
25
|
+
export { LiveBasketPageContext } from './chrome/live-basket-page-context';
|
|
26
|
+
export {
|
|
27
|
+
LiveOrderSuccessPageContext,
|
|
28
|
+
LiveAccountOrderPageContext
|
|
29
|
+
} from './chrome/live-order-page-context';
|
|
30
|
+
export { LiveCheckoutPageContext } from './chrome/live-checkout-page-context';
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
buildProductPageContext,
|
|
34
|
+
buildListingPageContext,
|
|
35
|
+
buildBasketPageContext,
|
|
36
|
+
buildAccountPageContext,
|
|
37
|
+
buildHomepagePageContext,
|
|
38
|
+
buildCustomPageContext,
|
|
39
|
+
buildOrderPageContext,
|
|
40
|
+
buildCheckoutPageContext,
|
|
41
|
+
buildFlatPageContext,
|
|
42
|
+
configurePageContextRoutes
|
|
43
|
+
} from './chrome/page-context';
|
|
25
44
|
|
|
26
45
|
export {
|
|
27
46
|
PLACEHOLDER_SLUGS,
|
|
@@ -182,6 +182,10 @@ export default function ThemePlaceholderClient({
|
|
|
182
182
|
<ThemeSection
|
|
183
183
|
section={section}
|
|
184
184
|
placeholderId={placeholderSlug}
|
|
185
|
+
// Without this the client render path resolves no bindings at
|
|
186
|
+
// all: the context is read for visibility rules and then dropped
|
|
187
|
+
// before the section that needs it.
|
|
188
|
+
pageContext={pageContext}
|
|
185
189
|
isDesigner={isDesigner}
|
|
186
190
|
isSelected={selectedSectionId === section.id}
|
|
187
191
|
selectedBlockId={selectedBlockId}
|
|
@@ -377,6 +377,17 @@ export default function ThemePlaceholderWrapper({
|
|
|
377
377
|
useEffect(() => {
|
|
378
378
|
if (typeof window === 'undefined') return;
|
|
379
379
|
if (window.self === window.top || !window.parent) return;
|
|
380
|
+
// Being framed is not the same as being edited: a storefront embedded by a
|
|
381
|
+
// payment provider would otherwise post sample values of the shopper's own
|
|
382
|
+
// page data to that embedder.
|
|
383
|
+
if (!isDesigner) return;
|
|
384
|
+
// A page carries several placeholders, and the editor keeps one field list.
|
|
385
|
+
// A slot with no context of its own (a header, a footer) used to overwrite
|
|
386
|
+
// the body's list with an empty one, so the designer's fields blinked out
|
|
387
|
+
// depending on which placeholder re-rendered last.
|
|
388
|
+
if (!resolvedPageContext || Object.keys(resolvedPageContext).length === 0) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
380
391
|
|
|
381
392
|
postToEditor({
|
|
382
393
|
type: 'PAGE_CONTEXT_DISCOVERY',
|
|
@@ -384,7 +395,7 @@ export default function ThemePlaceholderWrapper({
|
|
|
384
395
|
discovery: createPageContextDiscovery(resolvedPageContext)
|
|
385
396
|
}
|
|
386
397
|
});
|
|
387
|
-
}, [resolvedPageContext]);
|
|
398
|
+
}, [resolvedPageContext, isDesigner]);
|
|
388
399
|
|
|
389
400
|
// Load fonts from initial sections on mount (for production site, not iframe)
|
|
390
401
|
useEffect(() => {
|
package/src/theme-section.tsx
CHANGED
|
@@ -124,7 +124,7 @@ export default function ThemeSection({
|
|
|
124
124
|
// React context to read. Publishing the storefront's locale before the
|
|
125
125
|
// bindings resolve is what stops an en-GB brand rendering Turkish number
|
|
126
126
|
// grouping in every repeated row.
|
|
127
|
-
const { locale } = useLocalization();
|
|
127
|
+
const { locale, currency: storefrontCurrency } = useLocalization();
|
|
128
128
|
setBindingLocale(locale);
|
|
129
129
|
|
|
130
130
|
useEffect(() => {
|
|
@@ -466,7 +466,11 @@ export default function ThemeSection({
|
|
|
466
466
|
|
|
467
467
|
const shouldApplyGenericBindings =
|
|
468
468
|
String(effectiveSection.type || '').toLowerCase() === 'custom' ||
|
|
469
|
-
effectiveSection.dataSource?.type === 'page-context'
|
|
469
|
+
effectiveSection.dataSource?.type === 'page-context' ||
|
|
470
|
+
// A page that publishes commerce data is itself a binding source, so a
|
|
471
|
+
// section sitting on one resolves its bindings without the designer
|
|
472
|
+
// creating a data source first.
|
|
473
|
+
Boolean(pageContext);
|
|
470
474
|
|
|
471
475
|
if (CustomSectionRenderer) {
|
|
472
476
|
return (
|
|
@@ -1009,7 +1013,8 @@ export default function ThemeSection({
|
|
|
1009
1013
|
block: resolvedBlock,
|
|
1010
1014
|
sectionDataSource: effectiveSection.dataSource,
|
|
1011
1015
|
pageContext,
|
|
1012
|
-
isDesigner
|
|
1016
|
+
isDesigner,
|
|
1017
|
+
currency: storefrontCurrency
|
|
1013
1018
|
})
|
|
1014
1019
|
: resolvedBlock;
|
|
1015
1020
|
const createActionHandler =
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { generateThemeCSS } from '../index';
|
|
2
|
+
import type { Section } from '../../theme-section';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The generator has no property whitelist: it kebab-cases whatever keys a
|
|
6
|
+
* composition carries and emits them. The editor's Advanced families rely on
|
|
7
|
+
* exactly that — 161 CSS properties are offered there with no renderer-side
|
|
8
|
+
* entry of any kind, so introducing a whitelist would silently kill every one
|
|
9
|
+
* of them, and nothing else in this package would notice.
|
|
10
|
+
*
|
|
11
|
+
* These are the properties that made the difference on real brand pages: the
|
|
12
|
+
* ogee section dividers (clip-path, the mask family), the italic serif copy
|
|
13
|
+
* (font-style), the overlapping decoration (overflow), the blended banner
|
|
14
|
+
* (mix-blend-mode).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const withBlockStyles = (styles: Record<string, unknown>): Section[] => [
|
|
18
|
+
{
|
|
19
|
+
id: 'sec-1',
|
|
20
|
+
type: 'custom',
|
|
21
|
+
name: 'Section',
|
|
22
|
+
label: 'Section',
|
|
23
|
+
order: 0,
|
|
24
|
+
hidden: false,
|
|
25
|
+
properties: {},
|
|
26
|
+
styles: {},
|
|
27
|
+
blocks: [
|
|
28
|
+
{
|
|
29
|
+
id: 'block-1',
|
|
30
|
+
type: 'group',
|
|
31
|
+
label: 'Group',
|
|
32
|
+
order: 0,
|
|
33
|
+
hidden: false,
|
|
34
|
+
properties: {},
|
|
35
|
+
styles,
|
|
36
|
+
blocks: []
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
} as unknown as Section
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
describe('the CSS generator passes any property through', () => {
|
|
43
|
+
it('emits properties no renderer or descriptor mentions', () => {
|
|
44
|
+
const css = generateThemeCSS(
|
|
45
|
+
withBlockStyles({
|
|
46
|
+
'clip-path': 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)',
|
|
47
|
+
'mask-image':
|
|
48
|
+
'radial-gradient(circle at 50% 0, transparent 24px, #000 25px)',
|
|
49
|
+
'mask-repeat': 'repeat-x',
|
|
50
|
+
'mask-size': '48px 48px',
|
|
51
|
+
'font-style': 'italic',
|
|
52
|
+
'mix-blend-mode': 'multiply',
|
|
53
|
+
overflow: 'visible'
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(css).toContain(
|
|
58
|
+
'clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)'
|
|
59
|
+
);
|
|
60
|
+
expect(css).toContain('mask-image: radial-gradient');
|
|
61
|
+
expect(css).toContain('mask-repeat: repeat-x');
|
|
62
|
+
expect(css).toContain('mask-size: 48px 48px');
|
|
63
|
+
expect(css).toContain('font-style: italic');
|
|
64
|
+
expect(css).toContain('mix-blend-mode: multiply');
|
|
65
|
+
expect(css).toContain('overflow: visible');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('kebab-cases a camelCase key on the way out', () => {
|
|
69
|
+
const css = generateThemeCSS(
|
|
70
|
+
withBlockStyles({ maskComposite: 'exclude', backdropFilter: 'blur(4px)' })
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(css).toContain('mask-composite: exclude');
|
|
74
|
+
expect(css).toContain('backdrop-filter: blur(4px)');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('resolves such a property per breakpoint like any other', () => {
|
|
78
|
+
const css = generateThemeCSS(
|
|
79
|
+
withBlockStyles({
|
|
80
|
+
'clip-path': {
|
|
81
|
+
desktop: 'circle(40%)',
|
|
82
|
+
tablet: 'circle(45%)',
|
|
83
|
+
mobile: 'none'
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(css).toContain('clip-path: circle(40%)');
|
|
89
|
+
expect(css).toContain('clip-path: circle(45%)');
|
|
90
|
+
expect(css).toContain('clip-path: none');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { isMoneyPath, readFieldSemantic } from './field-semantics';
|
|
2
|
+
import { createPageContextDiscovery } from './page-context-discovery';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The designer's question is "what is this field?", and `typeof` cannot answer
|
|
6
|
+
* it: commerce publishes a total, a product URL and a title as three strings.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
describe('readFieldSemantic', () => {
|
|
10
|
+
it('reads money from the leaf name, under any root', () => {
|
|
11
|
+
expect(readFieldSemantic('basket.totalAmount', 'string')).toBe('money');
|
|
12
|
+
expect(readFieldSemantic('order.shippingAmount', 'string')).toBe('money');
|
|
13
|
+
expect(readFieldSemantic('product.price', 'string')).toBe('money');
|
|
14
|
+
expect(readFieldSemantic('basket.items[].unitPrice', 'string')).toBe(
|
|
15
|
+
'money'
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('separates the string fields a designer would otherwise confuse', () => {
|
|
20
|
+
expect(readFieldSemantic('product.image', 'string')).toBe('image');
|
|
21
|
+
expect(readFieldSemantic('product.url', 'string')).toBe('url');
|
|
22
|
+
expect(readFieldSemantic('product.title', 'string')).toBe('text');
|
|
23
|
+
expect(readFieldSemantic('order.created_at', 'string')).toBe('date');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps the observed type where the value already answers', () => {
|
|
27
|
+
expect(readFieldSemantic('basket.totalQuantity', 'number')).toBe('count');
|
|
28
|
+
expect(readFieldSemantic('basket.isEmpty', 'boolean')).toBe('flag');
|
|
29
|
+
expect(readFieldSemantic('basket.items', 'array')).toBe('list');
|
|
30
|
+
expect(readFieldSemantic('basket.discounts', 'object')).toBe('group');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('agrees with the formatter about what money is', () => {
|
|
34
|
+
// Same list on both sides: a field the picker labels money is a field the
|
|
35
|
+
// renderer formats as money.
|
|
36
|
+
['basket.totalAmount', 'order.amount', 'product.retailPrice'].forEach(
|
|
37
|
+
(path) => {
|
|
38
|
+
expect(isMoneyPath(path)).toBe(true);
|
|
39
|
+
expect(readFieldSemantic(path, 'string')).toBe('money');
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(isMoneyPath('basket.voucherCode')).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('discovery publishes the semantic', () => {
|
|
48
|
+
it('labels every field of a basket page', () => {
|
|
49
|
+
const discovery = createPageContextDiscovery({
|
|
50
|
+
basket: {
|
|
51
|
+
totalAmount: '1299.90',
|
|
52
|
+
totalQuantity: 2,
|
|
53
|
+
isEmpty: false,
|
|
54
|
+
voucherCode: 'WELCOME',
|
|
55
|
+
items: [{ title: 'Sneaker', image: '/i.jpg', unitPrice: '649.95' }]
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const byPath = Object.fromEntries(
|
|
60
|
+
discovery.entries.map((entry) => [entry.path, entry])
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(byPath['basket.totalAmount'].semantic).toBe('money');
|
|
64
|
+
expect(byPath['basket.totalAmount'].sample).toBe('1299.90');
|
|
65
|
+
expect(byPath['basket.totalQuantity'].semantic).toBe('count');
|
|
66
|
+
expect(byPath['basket.isEmpty'].semantic).toBe('flag');
|
|
67
|
+
expect(byPath['basket.voucherCode'].semantic).toBe('text');
|
|
68
|
+
expect(byPath['basket.items'].semantic).toBe('list');
|
|
69
|
+
expect(byPath['basket.items[].image'].semantic).toBe('image');
|
|
70
|
+
expect(byPath['basket.items[].unitPrice'].semantic).toBe('money');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a page-context field MEANS, as opposed to what JavaScript type it
|
|
3
|
+
* happens to be.
|
|
4
|
+
*
|
|
5
|
+
* Commerce publishes money as a raw string ("1299.90"), a product URL as a
|
|
6
|
+
* string and an image as a string, so a designer reading a picker sees three
|
|
7
|
+
* identical `string` fields and no way to tell which one belongs in an image
|
|
8
|
+
* block. The renderer needs the same answer to decide whether to format a
|
|
9
|
+
* bound value as money.
|
|
10
|
+
*
|
|
11
|
+
* One module so the two sides cannot drift: the formatter and the field the
|
|
12
|
+
* editor labels are derived from this list, not from two copies of it.
|
|
13
|
+
*/
|
|
14
|
+
export type FieldSemantic =
|
|
15
|
+
| 'money'
|
|
16
|
+
| 'count'
|
|
17
|
+
| 'flag'
|
|
18
|
+
| 'url'
|
|
19
|
+
| 'image'
|
|
20
|
+
| 'date'
|
|
21
|
+
| 'list'
|
|
22
|
+
| 'group'
|
|
23
|
+
| 'text';
|
|
24
|
+
|
|
25
|
+
/** Leaf names commerce publishes as money, whatever root they sit under. */
|
|
26
|
+
export const MONEY_LEAVES = new Set([
|
|
27
|
+
'totalAmount',
|
|
28
|
+
'totalProductAmount',
|
|
29
|
+
'totalDiscountAmount',
|
|
30
|
+
'totalPrice',
|
|
31
|
+
'unitPrice',
|
|
32
|
+
'retailPrice',
|
|
33
|
+
'shippingAmount',
|
|
34
|
+
'amount',
|
|
35
|
+
'price'
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const IMAGE_LEAVES = new Set(['image', 'thumbnail', 'logo', 'icon']);
|
|
39
|
+
const URL_LEAVES = new Set(['url', 'href', 'link', 'absolute_url', 'path']);
|
|
40
|
+
// Both spellings occur side by side: commerce sends `created_date`, the
|
|
41
|
+
// builders publish `createdDate`.
|
|
42
|
+
const DATE_LEAF_PATTERN = /(date|Date|_at|At)$/;
|
|
43
|
+
|
|
44
|
+
const readLeaf = (path: string): string => {
|
|
45
|
+
const withoutWildcard = path.replace(/\[\]$/, '');
|
|
46
|
+
return withoutWildcard.split('.').pop() || '';
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** True for a path the renderer must format as money. */
|
|
50
|
+
export const isMoneyPath = (path: string): boolean =>
|
|
51
|
+
Boolean(path) && MONEY_LEAVES.has(readLeaf(path));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The semantic of a value at a path. `valueType` is what the walk observed, so
|
|
55
|
+
* a number stays a count and a boolean stays a flag even if its name suggests
|
|
56
|
+
* otherwise — the name only disambiguates the strings, which is where commerce
|
|
57
|
+
* hides everything.
|
|
58
|
+
*/
|
|
59
|
+
export const readFieldSemantic = (
|
|
60
|
+
path: string,
|
|
61
|
+
valueType: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array'
|
|
62
|
+
): FieldSemantic => {
|
|
63
|
+
if (valueType === 'array') return 'list';
|
|
64
|
+
if (valueType === 'object') return 'group';
|
|
65
|
+
if (valueType === 'boolean') return 'flag';
|
|
66
|
+
|
|
67
|
+
const leaf = readLeaf(path);
|
|
68
|
+
|
|
69
|
+
if (MONEY_LEAVES.has(leaf)) return 'money';
|
|
70
|
+
if (valueType === 'number') return 'count';
|
|
71
|
+
if (IMAGE_LEAVES.has(leaf)) return 'image';
|
|
72
|
+
if (URL_LEAVES.has(leaf)) return 'url';
|
|
73
|
+
if (DATE_LEAF_PATTERN.test(leaf)) return 'date';
|
|
74
|
+
|
|
75
|
+
return 'text';
|
|
76
|
+
};
|
|
@@ -215,7 +215,9 @@ describe('iterator-utils', () => {
|
|
|
215
215
|
pageContext
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
|
|
218
|
+
// A bound total is money now, not the raw commerce string: it used to
|
|
219
|
+
// print "799.90" beside a built-in summary block printing "1.299,90 TL".
|
|
220
|
+
expect(resolvedText.value).toBe('799,90 TL');
|
|
219
221
|
expect(iteratorBlock.blocks).toHaveLength(1);
|
|
220
222
|
expect(iteratorBlock.blocks?.[0].value).toBe(
|
|
221
223
|
'https://cdn.example.com/image-1.jpg'
|