@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.
@@ -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: toStringValue(profileInfo?.phone),
383
- gender: toStringValue(profileInfo?.gender)
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(() => {
@@ -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
- expect(resolvedText.value).toBe('799.90');
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'