@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.
- package/CHANGELOG.md +16 -33
- package/package.json +4 -4
- package/src/__tests__/collection-row-scope.test.tsx +187 -0
- package/src/__tests__/group-row-styles.test.tsx +110 -0
- package/src/__tests__/theme-typography-vars.test.tsx +125 -0
- package/src/blocks/__tests__/listing-row-swatches.test.ts +111 -0
- package/src/blocks/group-block.tsx +35 -1
- package/src/blocks/item-template-utils.ts +34 -0
- package/src/blocks/item-template.tsx +6 -1
- package/src/blocks/listing-context.ts +17 -622
- package/src/blocks/listing-row.ts +692 -0
- package/src/components/with-designer-features.tsx +6 -0
- package/src/sections/__tests__/posts-slider-row-visibility.test.tsx +147 -0
- package/src/sections/posts-slider-section.tsx +23 -2
- package/src/theme-block.tsx +63 -2
- package/src/theme-placeholder-client.tsx +4 -3
- package/src/theme-placeholder-wrapper.tsx +61 -5
- package/src/utils/__tests__/property-switches.test.tsx +250 -0
- package/src/utils/index.ts +55 -3
- package/src/utils/iterator-price-shapes.test.ts +465 -0
- package/src/utils/iterator-utils.ts +371 -35
- package/src/utils/property-switches.ts +230 -0
|
@@ -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
|
|
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 {
|
package/src/theme-block.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
63
|
-
sections,
|
|
64
|
-
|
|
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
|
-
() =>
|
|
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
|
-
() =>
|
|
995
|
-
|
|
1046
|
+
() =>
|
|
1047
|
+
applyPropertySwitchesToSections(
|
|
1048
|
+
applyVisibilityRulesToSections(sections, visibilityContext),
|
|
1049
|
+
currentBreakpoint
|
|
1050
|
+
),
|
|
1051
|
+
[sections, visibilityContext, currentBreakpoint]
|
|
996
1052
|
);
|
|
997
1053
|
|
|
998
1054
|
return (
|