@akinon/pz-theme 2.0.103-rc.0 → 2.0.104-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.
@@ -11,6 +11,10 @@ import {
11
11
  } from '../hooks/use-block-action';
12
12
 
13
13
  import { isTrustedEditorEvent } from '../editor-bridge';
14
+ import {
15
+ HOVER_SCOPE_ATTRIBUTE,
16
+ REVEAL_ON_HOVER_ATTRIBUTE
17
+ } from '../utils/property-switches';
14
18
  interface WithDesignerFeaturesProps {
15
19
  block: any;
16
20
  placeholderId: string;
@@ -145,6 +149,8 @@ export function WithDesignerFeatures({
145
149
  <div
146
150
  ref={blockRef}
147
151
  data-block-id={block.styleSourceId || block.id}
152
+ {...(block.hoverScope ? { [HOVER_SCOPE_ATTRIBUTE]: '' } : null)}
153
+ {...(block.revealOnHover ? { [REVEAL_ON_HOVER_ATTRIBUTE]: '' } : null)}
148
154
  className={clsx(
149
155
  'relative',
150
156
  className,
@@ -0,0 +1,147 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+
4
+ /**
5
+ * ZERO-4829 — the brand's "GANT Seçtiklerimiz" slider card puts the plain
6
+ * price (black) and the sale price (red) in two texts that decide per row on
7
+ * `item.hasDiscount`. The slider expands its slides itself, without the
8
+ * section iterator, and used to apply neither per-row visibility nor the
9
+ * listing-row shape — so both prices printed on every slide.
10
+ */
11
+ jest.mock('@akinon/next/data/client/product', () => ({
12
+ __esModule: true,
13
+ useGetProductByPkQuery: () => ({ data: undefined, isFetching: false }),
14
+ useGetProductByParamsQuery: () => ({ data: undefined, isFetching: false }),
15
+ useAddProductMutation: () => [() => ({ unwrap: () => Promise.resolve({}) }), { isLoading: false }]
16
+ }));
17
+ jest.mock('@akinon/next/data/client/wishlist', () => ({
18
+ __esModule: true,
19
+ useGetFavoritesQuery: () => ({ data: undefined, isFetching: false }),
20
+ useAddFavoriteMutation: () => [() => ({ unwrap: () => Promise.resolve({}) }), { isLoading: false }],
21
+ useRemoveFavoriteMutation: () => [() => ({ unwrap: () => Promise.resolve({}) }), { isLoading: false }]
22
+ }));
23
+ jest.mock('../../theme-block', () => ({
24
+ __esModule: true,
25
+ default: ({ block }: { block: unknown }) => (
26
+ <div data-testid="slide" data-block={JSON.stringify(block)} />
27
+ )
28
+ }));
29
+ jest.mock('react-multi-carousel', () => ({
30
+ __esModule: true,
31
+ default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>
32
+ }));
33
+
34
+ import PostsSliderSection from '../posts-slider-section';
35
+
36
+ type Node = {
37
+ label?: string;
38
+ hidden?: boolean;
39
+ value?: unknown;
40
+ properties?: Record<string, unknown>;
41
+ blocks?: Node[];
42
+ };
43
+
44
+ const text = (label: string, binding: string, extra: Record<string, unknown> = {}): Node => ({
45
+ label,
46
+ hidden: false,
47
+ properties: { tag: 'p', dataBinding: binding, ...extra }
48
+ });
49
+
50
+ const ITERATOR = {
51
+ id: 'iter',
52
+ label: 'Iterator',
53
+ isIterator: true,
54
+ order: 0,
55
+ properties: { iteratorCount: 2 },
56
+ styles: {},
57
+ blocks: [
58
+ {
59
+ id: 'card',
60
+ type: 'group',
61
+ label: 'Product Card 1',
62
+ hidden: false,
63
+ properties: { tag: 'a', dataBinding: 'item.absolute_url' },
64
+ styles: {},
65
+ blocks: [
66
+ text('Retail', 'item.retail_price'),
67
+ text('Price', 'item.price', { visibleWhenField: 'item.hasDiscount', visibleWhenMode: 'falsy' }),
68
+ text('Sale Price', 'item.price', { visibleWhenField: 'item.hasDiscount', visibleWhenMode: 'truthy' }),
69
+ text('Chip', 'item.discount_percentage'),
70
+ text('Url', 'item.url'),
71
+ text('Badge', 'item.attributes.badge2')
72
+ ]
73
+ }
74
+ ]
75
+ };
76
+
77
+ const renderSlider = (products: Record<string, unknown>[], type = 'flash-deals-slider') =>
78
+ render(
79
+ <PostsSliderSection
80
+ {...({
81
+ section: {
82
+ id: 'sec',
83
+ type,
84
+ label: 'sec',
85
+ properties: { 'items-per-slide': 2 },
86
+ styles: {},
87
+ blocks: [ITERATOR],
88
+ dataSource: { type: 'collection', details: { collection: { products } } }
89
+ },
90
+ currentBreakpoint: 'desktop',
91
+ placeholderId: 'ph',
92
+ isDesigner: false
93
+ } as any)}
94
+ />
95
+ );
96
+
97
+ const slides = (): Node[] =>
98
+ screen.getAllByTestId('slide').map((el) => JSON.parse(el.getAttribute('data-block') || '{}'));
99
+
100
+ const child = (slide: Node, label: string): Node =>
101
+ (slide.blocks || []).find((b) => b.label === label) as Node;
102
+
103
+ describe('per-row visibility on a product slide', () => {
104
+ it('shows the sale price on a discounted row and the plain price on the other', () => {
105
+ renderSlider([
106
+ { pk: 1, name: 'A', absolute_url: '/a/', attributes: { badge2: 'OUTLET' }, active_price: { price: '599.00', retail_price: '1999.00', currency_type: 'try' } },
107
+ { pk: 2, name: 'B', absolute_url: '/b/', active_price: { price: '1099.00', retail_price: '1099.00', currency_type: 'try' } }
108
+ ]);
109
+ const [discounted, plain] = slides();
110
+
111
+ expect(child(discounted, 'Sale Price').hidden).toBe(false);
112
+ expect(child(discounted, 'Price').hidden).toBe(true);
113
+ expect(child(discounted, 'Retail').hidden).toBe(false);
114
+ expect(child(discounted, 'Retail').value).toBe('1.999 TL');
115
+ expect(child(discounted, 'Chip').value).toBe('-%70');
116
+
117
+ expect(child(plain, 'Price').hidden).toBe(false);
118
+ expect(child(plain, 'Price').value).toBe('1.099 TL');
119
+ expect(child(plain, 'Sale Price').hidden).toBe(true);
120
+ expect(child(plain, 'Retail').hidden).toBe(true);
121
+ expect(child(plain, 'Chip').hidden).toBe(true);
122
+ });
123
+
124
+ it('publishes the slide in the listing row shape too, without touching its own fields', () => {
125
+ renderSlider([
126
+ { pk: 1, name: 'A', absolute_url: '/a/', attributes: { badge2: 'OUTLET' }, active_price: { price: '599.00', retail_price: '1999.00', currency_type: 'try' } }
127
+ ]);
128
+ const [slide] = slides();
129
+ expect(child(slide, 'Url').value).toBe('/a/');
130
+ expect(child(slide, 'Badge').value).toBe('OUTLET');
131
+ expect(slide.properties?.href).toBe('/a/');
132
+ });
133
+
134
+ it('leaves a placeholder slide (no rows yet) with every block in place', () => {
135
+ renderSlider([]);
136
+ for (const slide of slides()) {
137
+ expect((slide.blocks || []).every((b) => b.hidden === false)).toBe(true);
138
+ }
139
+ });
140
+
141
+ it('does not reshape the rows of a posts slider', () => {
142
+ renderSlider([{ pk: 1, name: 'Post', absolute_url: '/post/' }], 'posts-slider');
143
+ const [slide] = slides();
144
+ // No listing-row field derived for a blog post.
145
+ expect(child(slide, 'Url').value).toBeUndefined();
146
+ });
147
+ });
@@ -10,9 +10,11 @@ import { safeDeepClone } from '../utils/safe-clone';
10
10
  import {
11
11
  getPriceWithCurrency,
12
12
  isPriceBinding,
13
+ normalizeCollectionRow,
13
14
  readProductCurrency,
14
15
  readProductPricePair
15
16
  } from '../utils/iterator-utils';
17
+ import { isVisibleForItem, readRowVisibility } from '../utils/row-visibility';
16
18
  import {
17
19
  applyFirstSlideImageLoading,
18
20
  getResponsiveValue,
@@ -29,6 +31,7 @@ import {
29
31
  import SliderQuickView from './slider-quick-view';
30
32
  import {
31
33
  DESIGNER_SAMPLE_PRODUCT,
34
+ publishesProductSlides,
32
35
  resolveSliderRowProduct
33
36
  } from '../blocks/item-template-utils';
34
37
  import type { ThemeProduct } from '../blocks/product-block-utils';
@@ -100,6 +103,18 @@ const replaceBlockValues = (
100
103
  const isPlaceholderMode =
101
104
  !productData || Object.keys(productData).length === 0;
102
105
 
106
+ // Per-row visibility, as the section iterator applies it: a price text that
107
+ // says "only on discounted rows" (`visibleWhenField: item.hasDiscount`)
108
+ // hides on the others — without it a card with a black plain price and a
109
+ // red sale price printed both on every slide. Never in placeholder mode:
110
+ // with no data every field reads empty and the canvas would lose the block.
111
+ if (!isPlaceholderMode) {
112
+ const visibility = readRowVisibility(newBlock.properties);
113
+ if (visibility && !isVisibleForItem(visibility, productData)) {
114
+ newBlock.hidden = true;
115
+ }
116
+ }
117
+
103
118
  if (newBlock.properties?.dataBinding && !isPlaceholderMode) {
104
119
  const bindingPath = String(newBlock.properties.dataBinding).replace(
105
120
  'item.',
@@ -113,7 +128,6 @@ const replaceBlockValues = (
113
128
  const { current, retail } = readProductPricePair(productData);
114
129
  if (retail > 0 && current > 0 && retail > current) {
115
130
  newBlock.value = `-%${Math.round((1 - current / retail) * 100)}`;
116
- newBlock.hidden = false;
117
131
  } else {
118
132
  newBlock.hidden = true;
119
133
  }
@@ -340,7 +354,14 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
340
354
  const actualCount = products.length > 0 ? products.length : fallbackCount;
341
355
 
342
356
  return Array.from({ length: actualCount }, (_, index) => {
343
- const product = products[index] || {};
357
+ const raw = products[index] || {};
358
+ // A product slide is published in the listing row's shape on top of
359
+ // its own fields (`item.url`, `item.retailPrice`, `item.hasDiscount`,
360
+ // `item.discountPercent`, `item.attributes` keyed, `item.colors`), the
361
+ // way a collection repeat's row is — one card design, every host.
362
+ const product = publishesProductSlides(section?.type)
363
+ ? normalizeCollectionRow(raw, index)
364
+ : raw;
344
365
  const cloned = replaceBlockValues(template, product, index);
345
366
 
346
367
  return {
@@ -3,7 +3,16 @@
3
3
  import React from 'react';
4
4
  import blockRendererRegistry from './blocks/block-renderer-registry';
5
5
  import { WithDesignerFeatures } from './components/with-designer-features';
6
- import { getResponsiveValue, isPublishWindowVisible } from './utils';
6
+ import {
7
+ ThemeItemScopeProvider,
8
+ type ThemeProduct
9
+ } from './blocks/product-block-utils';
10
+ import { DESIGNER_SAMPLE_PRODUCT } from './blocks/item-template-utils';
11
+ import {
12
+ getInnerRootInlineStyles,
13
+ getResponsiveValue,
14
+ isPublishWindowVisible
15
+ } from './utils';
7
16
  import { devWarn } from './utils/dev-warn';
8
17
 
9
18
  /**
@@ -45,7 +54,22 @@ export interface Block {
45
54
  locked?: boolean;
46
55
  blocks?: Block[]; // Optional - only group blocks have nested blocks
47
56
  isIterator?: boolean;
57
+ /**
58
+ * Set on the clones of a repeat whose rows are products (a collection data
59
+ * source): the row seen as the ambient product, or null for a padding row.
60
+ * Never authored — written by `buildIteratorBlock`, read here to open the
61
+ * product scope the row's favourite / add-to-basket resolve against.
62
+ */
63
+ itemScopeProduct?: ThemeProduct | null;
48
64
  iteratorDataPath?: string;
65
+ /**
66
+ * Written by the property-switch pass (utils/property-switches.ts), never
67
+ * authored: this block is revealed on hover of its `hoverScope` ancestor,
68
+ * and this block is such an ancestor. The wrapper writes both as data
69
+ * attributes; the CSS lives beside the pass.
70
+ */
71
+ revealOnHover?: boolean;
72
+ hoverScope?: boolean;
49
73
  /**
50
74
  * Properties this clone resolved for itself from its row. One generated rule
51
75
  * serves every clone, so these are the values that have to be inlined on the
@@ -112,6 +136,25 @@ export default function ThemeBlock({
112
136
  getResponsiveValue(block.properties?.htmlId, currentBreakpoint, '')
113
137
  );
114
138
 
139
+ /**
140
+ * A group's styles land on this wrapper (its own element is
141
+ * `display: contents`), so a value its row resolved for it — a swatch chip's
142
+ * `background-image: linear-gradient({{item.color}}, …)` — has to be inlined
143
+ * HERE: one generated rule serves every clone and cannot carry a per-row
144
+ * value. Text, link and button inline theirs on their own root; an image on
145
+ * its own element. Read with no theme settings so only the row's keys are
146
+ * kept, exactly as the row resolved them.
147
+ */
148
+ const wrapperRowStyle =
149
+ block.type === 'group' && block.rowStyleKeys && block.rowStyleKeys.length
150
+ ? getInnerRootInlineStyles(
151
+ block.styles || {},
152
+ undefined,
153
+ currentBreakpoint,
154
+ block.rowStyleKeys
155
+ )
156
+ : undefined;
157
+
115
158
  const renderedBlock = (
116
159
  <BlockRenderer
117
160
  block={block}
@@ -130,7 +173,7 @@ export default function ThemeBlock({
130
173
  />
131
174
  );
132
175
 
133
- return (
176
+ const rendered = (
134
177
  <WithDesignerFeatures
135
178
  block={block}
136
179
  placeholderId={placeholderId}
@@ -138,6 +181,7 @@ export default function ThemeBlock({
138
181
  isDesigner={isDesigner}
139
182
  isSelected={isSelected}
140
183
  currentBreakpoint={currentBreakpoint}
184
+ style={wrapperRowStyle}
141
185
  onMoveUp={onMoveUp}
142
186
  onMoveDown={onMoveDown}
143
187
  onDuplicate={onDuplicate}
@@ -179,4 +223,21 @@ export default function ThemeBlock({
179
223
  )}
180
224
  </WithDesignerFeatures>
181
225
  );
226
+
227
+ if (block.itemScopeProduct === undefined) return rendered;
228
+
229
+ // A clone of a collection repeat: the row is the ambient product for every
230
+ // block inside it (see `Block.itemScopeProduct`). Context only — the
231
+ // provider renders no element, so the markup is unchanged. The designer's
232
+ // padding rows get the sample product, as a product grid's do, so the card
233
+ // can be judged as a card and not as a set of "no product" boxes.
234
+ return (
235
+ <ThemeItemScopeProvider
236
+ product={
237
+ block.itemScopeProduct ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
238
+ }
239
+ >
240
+ {rendered}
241
+ </ThemeItemScopeProvider>
242
+ );
182
243
  }
@@ -9,6 +9,7 @@ import {
9
9
  } from './placeholder-registry';
10
10
  import { useVisibilityContext } from './hooks/use-visibility-context';
11
11
  import { applyVisibilityRulesToSections } from './utils/visibility-rules';
12
+ import { applyPropertySwitchesToSections } from './utils/property-switches';
12
13
  import { isPublishWindowVisible } from './utils/publish-window';
13
14
  import { useThemePageContext } from './theme-page-context';
14
15
 
@@ -59,9 +60,9 @@ export default function ThemePlaceholderClient({
59
60
  pageContext,
60
61
  isDesigner: isEditing
61
62
  });
62
- const renderedSections = applyVisibilityRulesToSections(
63
- sections,
64
- visibilityContext
63
+ const renderedSections = applyPropertySwitchesToSections(
64
+ applyVisibilityRulesToSections(sections, visibilityContext),
65
+ currentBreakpoint
65
66
  );
66
67
 
67
68
  useEffect(() => {
@@ -16,12 +16,16 @@ import {
16
16
  import { useThemePageContext } from './theme-page-context';
17
17
  import { ThemePageContextProvider } from './theme-page-context';
18
18
  import { ThemeSettingsProvider } from './theme-settings-context';
19
- import { generateThemeCSS } from './utils';
19
+ import { generateThemeCSS, readThemeTypographyFont } from './utils';
20
20
  import { breakpointForWidth } from './utils/breakpoints';
21
21
  import { PartStatePreview } from './components/part-state-preview';
22
22
  import { useVisibilityContext } from './hooks/use-visibility-context';
23
23
  import { createPageContextDiscovery } from './utils/page-context-discovery';
24
24
  import { applyVisibilityRulesToSections } from './utils/visibility-rules';
25
+ import {
26
+ applyPropertySwitchesToSections,
27
+ HOVER_REVEAL_CSS
28
+ } from './utils/property-switches';
25
29
  import { isPublishWindowVisible } from './utils/publish-window';
26
30
  import { buildClientRequestUrl } from '@akinon/next/utils';
27
31
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
@@ -113,6 +117,17 @@ const injectThemeColors = (settings: Record<string, unknown>) => {
113
117
  String(settings.backgroundOpacity)
114
118
  );
115
119
  }
120
+ // Typography families, for the same consumers as buildThemeTypographyCss.
121
+ if (Array.isArray(settings.typography)) {
122
+ settings.typography.forEach((variant) => {
123
+ const key = (variant as { key?: unknown })?.key;
124
+ if (typeof key !== 'string' || !/^[a-z0-9-]+$/i.test(key)) return;
125
+ const family = readThemeTypographyFont(settings, key);
126
+ if (family) root.style.setProperty(`--theme-font-${key}`, family);
127
+ else root.style.removeProperty(`--theme-font-${key}`);
128
+ });
129
+ }
130
+
116
131
  if (settings.breadcrumbSeparator) {
117
132
  root.style.setProperty(
118
133
  '--theme-breadcrumb-separator',
@@ -158,6 +173,33 @@ const buildThemeBackgroundCss = (
158
173
  return `:root{--theme-background:${value}}body{background-color:${value}}`;
159
174
  };
160
175
 
176
+ /**
177
+ * The theme's typography families as `--theme-font-<variant>` on `:root`,
178
+ * emitted with the markup for the same reason as the background above. Block
179
+ * styles referencing them are resolved from the settings directly
180
+ * (`resolveThemeCssVariables`); the variables serve everything else — a
181
+ * template's `customCss`, a brand's own stylesheet — and the editor's live
182
+ * typography edits (kept in step by `injectThemeColors`).
183
+ */
184
+ export const buildThemeTypographyCss = (
185
+ settings: Record<string, unknown> | null
186
+ ): string => {
187
+ const variants = settings?.typography;
188
+ if (!Array.isArray(variants)) return '';
189
+
190
+ const declarations = variants
191
+ .map((variant) => {
192
+ const key = (variant as { key?: unknown })?.key;
193
+ if (typeof key !== 'string' || !/^[a-z0-9-]+$/i.test(key)) return '';
194
+ const family = readThemeTypographyFont(settings, key);
195
+ if (!family) return '';
196
+ return `--theme-font-${key}:${neutralizeStyleTagBreakout(family)}`;
197
+ })
198
+ .filter(Boolean);
199
+
200
+ return declarations.length ? `:root{${declarations.join(';')}}` : '';
201
+ };
202
+
161
203
  // Helper function to load Google Font
162
204
  const loadGoogleFont = (fontFamily: string, fontWeight?: string) => {
163
205
  if (!fontFamily || fontFamily === 'inherit') return;
@@ -558,7 +600,7 @@ export default function ThemePlaceholderWrapper({
558
600
  sources: { dataUrl: string; format: string }[];
559
601
  }[];
560
602
 
561
- const inject = (font: typeof customFonts[number]) => {
603
+ const inject = (font: (typeof customFonts)[number]) => {
562
604
  if (
563
605
  !font?.name ||
564
606
  !Array.isArray(font.sources) ||
@@ -983,7 +1025,17 @@ export default function ThemePlaceholderWrapper({
983
1025
  [sections, isDesigner, currentBreakpoint]
984
1026
  );
985
1027
  const themeBackgroundCSS = useMemo(
986
- () => buildThemeBackgroundCss(themeSettings),
1028
+ () =>
1029
+ [
1030
+ buildThemeBackgroundCss(themeSettings),
1031
+ buildThemeTypographyCss(themeSettings),
1032
+ // Static, and tiny: the rules for blocks a property switch reveals on
1033
+ // hover (utils/property-switches.ts). Emitted with the theme CSS so
1034
+ // the storefront and the canvas hover the same way.
1035
+ HOVER_REVEAL_CSS
1036
+ ]
1037
+ .filter(Boolean)
1038
+ .join(''),
987
1039
  [themeSettings]
988
1040
  );
989
1041
  const visibilityContext = useVisibilityContext(currentBreakpoint, {
@@ -991,8 +1043,12 @@ export default function ThemePlaceholderWrapper({
991
1043
  isDesigner: isEditing
992
1044
  });
993
1045
  const renderedSections = useMemo(
994
- () => applyVisibilityRulesToSections(sections, visibilityContext),
995
- [sections, visibilityContext]
1046
+ () =>
1047
+ applyPropertySwitchesToSections(
1048
+ applyVisibilityRulesToSections(sections, visibilityContext),
1049
+ currentBreakpoint
1050
+ ),
1051
+ [sections, visibilityContext, currentBreakpoint]
996
1052
  );
997
1053
 
998
1054
  return (