@akinon/pz-theme 2.0.82-beta.0 → 2.0.82
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 -4
- package/package.json +4 -4
- package/src/blocks/__tests__/link-block.test.tsx +87 -0
- package/src/blocks/__tests__/text-block.test.tsx +88 -0
- package/src/blocks/__tests__/video-block.test.tsx +207 -2
- package/src/blocks/item-template-utils.ts +51 -5
- package/src/blocks/link-block.tsx +43 -0
- package/src/blocks/slider-block.tsx +92 -4
- package/src/blocks/text-block.tsx +42 -4
- package/src/blocks/video-block.tsx +208 -80
- package/src/sections/__tests__/posts-slider-arrows.test.tsx +470 -0
- package/src/sections/__tests__/posts-slider-edges.test.tsx +394 -0
- package/src/sections/__tests__/posts-slider-quick-view-no-fetch.test.tsx +205 -0
- package/src/sections/__tests__/posts-slider-quick-view.test.tsx +660 -0
- package/src/sections/posts-slider-section.tsx +482 -115
- package/src/sections/slider-quick-view.tsx +375 -0
- package/src/utils/__tests__/line-breaks.test.ts +215 -0
- package/src/utils/index.ts +12 -1
- package/src/utils/iterator-utils.ts +46 -0
- package/src/utils/line-breaks.ts +142 -0
- package/src/utils/slider-arrows.tsx +252 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
|
|
6
|
+
import { partAttrs } from '../utils';
|
|
7
|
+
import { useOverlay } from '../hooks/use-overlay';
|
|
8
|
+
import FavoriteButtonBlock from '../blocks/favorite-button-block';
|
|
9
|
+
import {
|
|
10
|
+
ThemeItemScopeProvider,
|
|
11
|
+
pickProductField,
|
|
12
|
+
type ThemeProduct
|
|
13
|
+
} from '../blocks/product-block-utils';
|
|
14
|
+
import {
|
|
15
|
+
getPriceWithCurrency,
|
|
16
|
+
readProductCurrency,
|
|
17
|
+
readProductPricePair
|
|
18
|
+
} from '../utils/iterator-utils';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The panel a product slider opens instead of leaving for the product page.
|
|
22
|
+
*
|
|
23
|
+
* Some brands' designs answer a tap on a card with a look at the product
|
|
24
|
+
* rather than a navigation; others do not want it at all. So this is drawn
|
|
25
|
+
* only when the section says `show-quick-view`, and it shows exactly what
|
|
26
|
+
* such a design shows: the photo, the name, the price, a way to favourite it
|
|
27
|
+
* and a way to go to the product page after all.
|
|
28
|
+
*
|
|
29
|
+
* It renders the row the shopper clicked and asks commerce for nothing. Every
|
|
30
|
+
* field — photo, name, price, address — comes off that one row, so the panel
|
|
31
|
+
* always describes exactly the product whose card was tapped.
|
|
32
|
+
*
|
|
33
|
+
* DO NOT ADD A BY-PK FETCH HERE. It was tried and reverted, and the reason is
|
|
34
|
+
* not performance. A collection data source is snapshotted from OMNITRON, so
|
|
35
|
+
* `row.pk` is an Omnitron product id; the storefront's `/product/<pk>` is a
|
|
36
|
+
* different id space. Asking it for `row.pk` returns a real, correct, and
|
|
37
|
+
* COMPLETELY DIFFERENT product — measured on a live store: row pk 48
|
|
38
|
+
* (sku 8684464001616, a bed sheet) answered with pk 48, a glass serving plate.
|
|
39
|
+
* The panel then linked one product under another's photo. A `sku` full-text
|
|
40
|
+
* search can bridge the two spaces, but its index excludes out-of-stock and
|
|
41
|
+
* non-listable products — precisely what a flash-deals strip runs into.
|
|
42
|
+
*
|
|
43
|
+
* So a row with no `absolute_url` gets no link, and that is the honest state:
|
|
44
|
+
* the same row leaves the card itself unlinked, because the card's
|
|
45
|
+
* `item.absolute_url` binding has nothing to bind either.
|
|
46
|
+
*
|
|
47
|
+
* Rendering the storefront's OWN widget answer instead — which does carry
|
|
48
|
+
* commerce-space products with `absolute_url`, and which
|
|
49
|
+
* `load-placeholder-sections.ts` already has in hand — was built and then
|
|
50
|
+
* deliberately reverted. It linked every card, but it also made the published
|
|
51
|
+
* strip show DIFFERENT PRODUCTS than the canvas the merchandiser laid out,
|
|
52
|
+
* because the two catalogues disagree about the collection's members. A
|
|
53
|
+
* merchandiser who cannot trust the canvas has lost more than a link. Until
|
|
54
|
+
* the two catalogues agree, what the editor shows is what the site shows.
|
|
55
|
+
*
|
|
56
|
+
* The surface is hand-rolled rather than taken from `overlays/overlay-surface`
|
|
57
|
+
* on purpose. Those primitives stamp their own `data-part="overlay"` and
|
|
58
|
+
* `data-part="modal"` and overwrite a caller's markers, so the eight
|
|
59
|
+
* `quick-view-*` part groups the editor offers would style nothing. What is
|
|
60
|
+
* NOT hand-rolled is the page contract — focus, the overlay stack, the scroll
|
|
61
|
+
* lock, Escape and `aria-modal` all stay with `useOverlay`, which exists
|
|
62
|
+
* because three blocks previously grew their own incompatible copies.
|
|
63
|
+
*
|
|
64
|
+
* Parts: quick-view-backdrop, quick-view-panel, quick-view-close,
|
|
65
|
+
* quick-view-media, quick-view-name, quick-view-price, quick-view-link,
|
|
66
|
+
* quick-view-favorite.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
interface SliderQuickViewProps {
|
|
70
|
+
/** The clicked row, already resolved to a product (pk guaranteed). */
|
|
71
|
+
row: ThemeProduct | null;
|
|
72
|
+
open: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Draw in the flow instead of over the page. The canvas preview: a fixed
|
|
75
|
+
* overlay there would cover the workspace the author is trying to style,
|
|
76
|
+
* and every dismissal route is inert anyway while `isDesigner` is set.
|
|
77
|
+
*/
|
|
78
|
+
inline?: boolean;
|
|
79
|
+
onClose: () => void;
|
|
80
|
+
openerRef?: { readonly current: HTMLElement | null };
|
|
81
|
+
/**
|
|
82
|
+
* Where focus lands when the card that opened the panel is gone by the time
|
|
83
|
+
* it closes — the carousel has moved on, or the data source refreshed. It
|
|
84
|
+
* has to be an element that OUTLIVES the panel, so it is the section band
|
|
85
|
+
* rather than anything rendered here.
|
|
86
|
+
*/
|
|
87
|
+
fallbackRef?: { readonly current: HTMLElement | null };
|
|
88
|
+
isDesigner?: boolean;
|
|
89
|
+
currentBreakpoint?: string;
|
|
90
|
+
linkLabel: string;
|
|
91
|
+
closeLabel: string;
|
|
92
|
+
showFavorite: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The row's photo. Commerce leaves `image` unset on collection payloads and
|
|
97
|
+
* carries the shot in `productimage_set`, which is why the card's own binding
|
|
98
|
+
* is `item.productimage_set[0].image`; both are read here so a row of either
|
|
99
|
+
* shape draws something.
|
|
100
|
+
*/
|
|
101
|
+
const readRowImage = (row: ThemeProduct | null): string => {
|
|
102
|
+
const direct = pickProductField<unknown>(row, 'image');
|
|
103
|
+
if (typeof direct === 'string' && direct) return direct;
|
|
104
|
+
|
|
105
|
+
const first = (row?.productimage_set || [])[0];
|
|
106
|
+
const fromSet = first?.image || first?.url;
|
|
107
|
+
|
|
108
|
+
return typeof fromSet === 'string' ? fromSet : '';
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const SliderQuickView = ({
|
|
112
|
+
row,
|
|
113
|
+
open,
|
|
114
|
+
inline = false,
|
|
115
|
+
onClose,
|
|
116
|
+
openerRef,
|
|
117
|
+
fallbackRef,
|
|
118
|
+
isDesigner = false,
|
|
119
|
+
currentBreakpoint = 'desktop',
|
|
120
|
+
linkLabel,
|
|
121
|
+
closeLabel,
|
|
122
|
+
showFavorite
|
|
123
|
+
}: SliderQuickViewProps) => {
|
|
124
|
+
// Unconditionally, before the early return below: an overlay that mounted
|
|
125
|
+
// and unmounted its own hook would take the stack registration with it.
|
|
126
|
+
const overlay = useOverlay<HTMLDivElement>({
|
|
127
|
+
// An inline panel is not over the page: it takes no focus, claims no
|
|
128
|
+
// modality and freezes nothing.
|
|
129
|
+
open: open && !inline,
|
|
130
|
+
onClose,
|
|
131
|
+
isDesigner,
|
|
132
|
+
modal: true,
|
|
133
|
+
returnFocusRef: openerRef,
|
|
134
|
+
fallbackFocusRef: fallbackRef
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (!open || !row) return null;
|
|
138
|
+
|
|
139
|
+
const name = String(pickProductField<unknown>(row, 'name') ?? '');
|
|
140
|
+
// `pickProductField` resolves `url` under this name too, so a row of either
|
|
141
|
+
// shape is read here. Nothing else is consulted — see the note above about
|
|
142
|
+
// why there is no fetch.
|
|
143
|
+
const href = String(pickProductField<unknown>(row, 'absolute_url') ?? '');
|
|
144
|
+
const image = readRowImage(row);
|
|
145
|
+
|
|
146
|
+
const { current: amount } = readProductPricePair(row);
|
|
147
|
+
const price =
|
|
148
|
+
amount > 0 ? getPriceWithCurrency(amount, readProductCurrency(row)) : '';
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* On a storefront a piece the row cannot fill is simply not drawn — a link
|
|
152
|
+
* to nowhere and an empty image frame are both worse than their absence.
|
|
153
|
+
*
|
|
154
|
+
* In the canvas the opposite is true: the author turned the preview on
|
|
155
|
+
* precisely to style these, and the sample row the editor falls back to
|
|
156
|
+
* before a data source is attached carries neither a URL nor a photo. So
|
|
157
|
+
* the designer gets the element regardless, inert and clearly a stand-in.
|
|
158
|
+
*/
|
|
159
|
+
const showLink = Boolean(href) || isDesigner;
|
|
160
|
+
const showMedia = Boolean(image) || isDesigner;
|
|
161
|
+
|
|
162
|
+
const dismiss = () => {
|
|
163
|
+
if (inline) return;
|
|
164
|
+
onClose();
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
data-slider-quick-view=""
|
|
170
|
+
{...partAttrs('quick-view-backdrop', { open: true })}
|
|
171
|
+
onClick={dismiss}
|
|
172
|
+
style={
|
|
173
|
+
inline
|
|
174
|
+
? {
|
|
175
|
+
position: 'static',
|
|
176
|
+
marginTop: '16px',
|
|
177
|
+
display: 'flex',
|
|
178
|
+
justifyContent: 'center'
|
|
179
|
+
}
|
|
180
|
+
: {
|
|
181
|
+
position: 'fixed',
|
|
182
|
+
inset: 0,
|
|
183
|
+
zIndex: 1000,
|
|
184
|
+
display: 'flex',
|
|
185
|
+
alignItems: 'center',
|
|
186
|
+
justifyContent: 'center',
|
|
187
|
+
padding: '16px',
|
|
188
|
+
background: 'rgba(0, 0, 0, 0.5)'
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
>
|
|
192
|
+
<div
|
|
193
|
+
{...partAttrs('quick-view-panel', { open: true })}
|
|
194
|
+
role="dialog"
|
|
195
|
+
aria-label={name || closeLabel}
|
|
196
|
+
tabIndex={-1}
|
|
197
|
+
{...overlay.overlayProps}
|
|
198
|
+
// The backdrop dismisses; a click on the panel itself must not reach
|
|
199
|
+
// that handler.
|
|
200
|
+
onClick={(event) => event.stopPropagation()}
|
|
201
|
+
style={{
|
|
202
|
+
position: 'relative',
|
|
203
|
+
display: 'flex',
|
|
204
|
+
flexDirection: 'column',
|
|
205
|
+
width: '100%',
|
|
206
|
+
maxWidth: '360px',
|
|
207
|
+
maxHeight: inline ? undefined : '85vh',
|
|
208
|
+
overflow: 'auto',
|
|
209
|
+
background: '#ffffff',
|
|
210
|
+
color: '#111827',
|
|
211
|
+
textAlign: 'center'
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{/*
|
|
215
|
+
* Always drawn, and deliberately not behind a toggle: with the
|
|
216
|
+
* backdrop click and Escape both gone in the inline case, a panel
|
|
217
|
+
* whose close button an author could remove would be a trap.
|
|
218
|
+
*/}
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
{...partAttrs('quick-view-close')}
|
|
222
|
+
aria-label={closeLabel}
|
|
223
|
+
onClick={(event) => {
|
|
224
|
+
event.stopPropagation();
|
|
225
|
+
if (!inline) onClose();
|
|
226
|
+
}}
|
|
227
|
+
style={{
|
|
228
|
+
alignSelf: 'flex-end',
|
|
229
|
+
display: 'inline-flex',
|
|
230
|
+
alignItems: 'center',
|
|
231
|
+
gap: '6px',
|
|
232
|
+
padding: '12px 16px',
|
|
233
|
+
border: 'none',
|
|
234
|
+
background: 'transparent',
|
|
235
|
+
color: 'inherit',
|
|
236
|
+
font: 'inherit',
|
|
237
|
+
cursor: 'pointer'
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
<span style={{ textDecoration: 'underline' }}>{closeLabel}</span>
|
|
241
|
+
<svg
|
|
242
|
+
width="14"
|
|
243
|
+
height="14"
|
|
244
|
+
viewBox="0 0 24 24"
|
|
245
|
+
fill="none"
|
|
246
|
+
stroke="currentColor"
|
|
247
|
+
strokeWidth="1.5"
|
|
248
|
+
aria-hidden="true"
|
|
249
|
+
>
|
|
250
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
251
|
+
</svg>
|
|
252
|
+
</button>
|
|
253
|
+
|
|
254
|
+
{showMedia &&
|
|
255
|
+
(image ? (
|
|
256
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
257
|
+
<img
|
|
258
|
+
{...partAttrs('quick-view-media')}
|
|
259
|
+
src={image}
|
|
260
|
+
alt={name}
|
|
261
|
+
style={{
|
|
262
|
+
width: '100%',
|
|
263
|
+
height: 'auto',
|
|
264
|
+
objectFit: 'contain',
|
|
265
|
+
display: 'block'
|
|
266
|
+
}}
|
|
267
|
+
/>
|
|
268
|
+
) : (
|
|
269
|
+
// Canvas stand-in: the same part, so the frame an author styles is
|
|
270
|
+
// the frame the shopper gets.
|
|
271
|
+
<div
|
|
272
|
+
{...partAttrs('quick-view-media', { empty: true })}
|
|
273
|
+
aria-hidden="true"
|
|
274
|
+
style={{
|
|
275
|
+
width: '100%',
|
|
276
|
+
aspectRatio: '3 / 4',
|
|
277
|
+
background: 'rgba(0, 0, 0, 0.06)'
|
|
278
|
+
}}
|
|
279
|
+
/>
|
|
280
|
+
))}
|
|
281
|
+
|
|
282
|
+
{name && (
|
|
283
|
+
<h3
|
|
284
|
+
{...partAttrs('quick-view-name')}
|
|
285
|
+
style={{
|
|
286
|
+
margin: '16px 16px 4px',
|
|
287
|
+
fontSize: '16px',
|
|
288
|
+
fontWeight: 400
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
{name}
|
|
292
|
+
</h3>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{price && (
|
|
296
|
+
<p
|
|
297
|
+
{...partAttrs('quick-view-price')}
|
|
298
|
+
style={{
|
|
299
|
+
margin: '0 16px 16px',
|
|
300
|
+
fontSize: '14px',
|
|
301
|
+
fontWeight: 600
|
|
302
|
+
}}
|
|
303
|
+
>
|
|
304
|
+
{price}
|
|
305
|
+
</p>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
<div
|
|
309
|
+
style={{
|
|
310
|
+
display: 'flex',
|
|
311
|
+
alignItems: 'center',
|
|
312
|
+
justifyContent: 'center',
|
|
313
|
+
gap: '16px',
|
|
314
|
+
padding: '16px',
|
|
315
|
+
borderTop: '1px solid rgba(0, 0, 0, 0.1)'
|
|
316
|
+
}}
|
|
317
|
+
>
|
|
318
|
+
{showLink && (
|
|
319
|
+
<NextLink
|
|
320
|
+
{...partAttrs('quick-view-link')}
|
|
321
|
+
// '#' is only ever reached in the canvas, where the click below
|
|
322
|
+
// is cancelled anyway.
|
|
323
|
+
href={href || '#'}
|
|
324
|
+
// A click in the canvas selects; it must not navigate the
|
|
325
|
+
// editor iframe away from the page being authored.
|
|
326
|
+
onClick={(event) => {
|
|
327
|
+
if (isDesigner) event.preventDefault();
|
|
328
|
+
}}
|
|
329
|
+
style={{
|
|
330
|
+
display: 'inline-flex',
|
|
331
|
+
alignItems: 'center',
|
|
332
|
+
justifyContent: 'center',
|
|
333
|
+
minWidth: '160px',
|
|
334
|
+
padding: '12px 24px',
|
|
335
|
+
border: '1px solid currentColor',
|
|
336
|
+
textDecoration: 'none',
|
|
337
|
+
color: 'inherit',
|
|
338
|
+
fontSize: '13px',
|
|
339
|
+
letterSpacing: '0.04em'
|
|
340
|
+
}}
|
|
341
|
+
>
|
|
342
|
+
{linkLabel}
|
|
343
|
+
</NextLink>
|
|
344
|
+
)}
|
|
345
|
+
|
|
346
|
+
{showFavorite && (
|
|
347
|
+
<span {...partAttrs('quick-view-favorite')}>
|
|
348
|
+
{/*
|
|
349
|
+
* The heart acts on the row the shopper clicked, not on the
|
|
350
|
+
* page's product. The scope is what every commerce block
|
|
351
|
+
* already reads through `useThemeProduct()`, so this needs no
|
|
352
|
+
* change in the button itself.
|
|
353
|
+
*/}
|
|
354
|
+
<ThemeItemScopeProvider product={row}>
|
|
355
|
+
<FavoriteButtonBlock
|
|
356
|
+
block={{
|
|
357
|
+
id: `quick-view-favorite-${String(row.pk)}`,
|
|
358
|
+
type: 'favorite-button',
|
|
359
|
+
properties: {}
|
|
360
|
+
}}
|
|
361
|
+
placeholderId=""
|
|
362
|
+
sectionId=""
|
|
363
|
+
currentBreakpoint={currentBreakpoint}
|
|
364
|
+
isDesigner={isDesigner}
|
|
365
|
+
/>
|
|
366
|
+
</ThemeItemScopeProvider>
|
|
367
|
+
</span>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
export default SliderQuickView;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LINE_BREAK_ATTRIBUTE,
|
|
3
|
+
buildLineBreakCss,
|
|
4
|
+
compositionUsesScopedLineBreaks
|
|
5
|
+
} from '../line-breaks';
|
|
6
|
+
import { generateThemeCSS } from '../index';
|
|
7
|
+
import { DESKTOP_MIN_WIDTH, TABLET_MIN_WIDTH } from '../breakpoints';
|
|
8
|
+
|
|
9
|
+
const block = (overrides: Record<string, unknown> = {}) =>
|
|
10
|
+
({
|
|
11
|
+
id: 'b1',
|
|
12
|
+
type: 'text',
|
|
13
|
+
label: 'Text',
|
|
14
|
+
properties: {},
|
|
15
|
+
styles: {},
|
|
16
|
+
order: 0,
|
|
17
|
+
hidden: false,
|
|
18
|
+
...overrides
|
|
19
|
+
} as any);
|
|
20
|
+
|
|
21
|
+
const section = (blocks: any[], overrides: Record<string, unknown> = {}) =>
|
|
22
|
+
({
|
|
23
|
+
id: 's1',
|
|
24
|
+
type: 'custom',
|
|
25
|
+
name: 'Custom',
|
|
26
|
+
label: 'Custom',
|
|
27
|
+
properties: {},
|
|
28
|
+
styles: {},
|
|
29
|
+
blocks,
|
|
30
|
+
order: 0,
|
|
31
|
+
hidden: false,
|
|
32
|
+
...overrides
|
|
33
|
+
} as any);
|
|
34
|
+
|
|
35
|
+
const scoped = (scope: string) =>
|
|
36
|
+
`Şehrin ritmiyle buluşan<br ${LINE_BREAK_ATTRIBUTE}="${scope}"> güneşli yaşam stili`;
|
|
37
|
+
|
|
38
|
+
describe('scoped line breaks', () => {
|
|
39
|
+
describe('the emitted sheet', () => {
|
|
40
|
+
it('hides every scoped break, then shows each scope inside its own band', () => {
|
|
41
|
+
const css = buildLineBreakCss();
|
|
42
|
+
|
|
43
|
+
expect(css).toContain(`br[${LINE_BREAK_ATTRIBUTE}]{display: none !important;}`);
|
|
44
|
+
expect(css).toContain(
|
|
45
|
+
`@media (min-width: ${DESKTOP_MIN_WIDTH}px){br[${LINE_BREAK_ATTRIBUTE}~="desktop"]{display: inline !important;}}`
|
|
46
|
+
);
|
|
47
|
+
expect(css).toContain(
|
|
48
|
+
`@media (min-width: ${TABLET_MIN_WIDTH}px) and (max-width: ${
|
|
49
|
+
DESKTOP_MIN_WIDTH - 1
|
|
50
|
+
}px){br[${LINE_BREAK_ATTRIBUTE}~="tablet"]{display: inline !important;}}`
|
|
51
|
+
);
|
|
52
|
+
expect(css).toContain(
|
|
53
|
+
`@media (max-width: ${
|
|
54
|
+
TABLET_MIN_WIDTH - 1
|
|
55
|
+
}px){br[${LINE_BREAK_ATTRIBUTE}~="mobile"]{display: inline !important;}}`
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('hides before it shows, so a scope reaches the browser as an override', () => {
|
|
60
|
+
const css = buildLineBreakCss();
|
|
61
|
+
const hideAt = css.indexOf(`br[${LINE_BREAK_ATTRIBUTE}]{display: none`);
|
|
62
|
+
const firstShowAt = css.indexOf('display: inline');
|
|
63
|
+
|
|
64
|
+
expect(hideAt).toBeGreaterThanOrEqual(0);
|
|
65
|
+
expect(firstShowAt).toBeGreaterThan(hideAt);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('leaves a plain <br> alone — every rule is attribute-qualified', () => {
|
|
69
|
+
const css = buildLineBreakCss();
|
|
70
|
+
const declarations = css.match(/br[^{]*\{/g) ?? [];
|
|
71
|
+
|
|
72
|
+
expect(declarations.length).toBeGreaterThan(0);
|
|
73
|
+
declarations.forEach((selector) => {
|
|
74
|
+
expect(selector).toContain(`[${LINE_BREAK_ATTRIBUTE}`);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('uses DISJOINT bands: a phone scope never reaches the tablet band', () => {
|
|
79
|
+
// The theme's own MOBILE_MEDIA_QUERY is max-width 1023 and overlaps the
|
|
80
|
+
// tablet band on purpose. A break's scope is an author-written list, not a
|
|
81
|
+
// value on the fall-through chain, so "mobile" here has to stop at 767 —
|
|
82
|
+
// otherwise `mobile` alone is indistinguishable from `mobile tablet` and
|
|
83
|
+
// the tablet-only intent becomes unreachable.
|
|
84
|
+
const css = buildLineBreakCss();
|
|
85
|
+
|
|
86
|
+
expect(css).toContain(`@media (max-width: ${TABLET_MIN_WIDTH - 1}px)`);
|
|
87
|
+
expect(css).not.toContain(
|
|
88
|
+
`@media (max-width: ${DESKTOP_MIN_WIDTH - 1}px){br[`
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('the editor canvas', () => {
|
|
94
|
+
it('resolves to the one band being drawn instead of a media query', () => {
|
|
95
|
+
const css = buildLineBreakCss('mobile');
|
|
96
|
+
|
|
97
|
+
expect(css).toContain(
|
|
98
|
+
`br[${LINE_BREAK_ATTRIBUTE}~="mobile"]{display: inline !important;}`
|
|
99
|
+
);
|
|
100
|
+
expect(css).not.toContain('@media');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('resolves each authorable breakpoint', () => {
|
|
104
|
+
(['desktop', 'tablet', 'mobile'] as const).forEach((bp) => {
|
|
105
|
+
const css = buildLineBreakCss(bp);
|
|
106
|
+
expect(css).toContain(`br[${LINE_BREAK_ATTRIBUTE}~="${bp}"]`);
|
|
107
|
+
expect(css).not.toContain('@media');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('falls back to the real queries on a breakpoint name it does not know', () => {
|
|
112
|
+
// Silently hiding every scoped break would make the canvas lie about the
|
|
113
|
+
// line count at a width the editor merely named differently.
|
|
114
|
+
const css = buildLineBreakCss('widescreen');
|
|
115
|
+
|
|
116
|
+
expect(css).toContain('@media');
|
|
117
|
+
expect(css).toContain(`br[${LINE_BREAK_ATTRIBUTE}~="desktop"]`);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('detection', () => {
|
|
122
|
+
it('finds a scoped break in a locale map', () => {
|
|
123
|
+
const sections = [
|
|
124
|
+
section([block({ value: { tr: scoped('mobile tablet'), en: 'plain' } })])
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
expect(compositionUsesScopedLineBreaks(sections)).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('finds a scoped break in a bare single-locale string', () => {
|
|
131
|
+
const sections = [section([block({ value: scoped('desktop') })])];
|
|
132
|
+
|
|
133
|
+
expect(compositionUsesScopedLineBreaks(sections)).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('finds one nested inside a group block', () => {
|
|
137
|
+
const sections = [
|
|
138
|
+
section([
|
|
139
|
+
block({
|
|
140
|
+
id: 'group',
|
|
141
|
+
type: 'group',
|
|
142
|
+
blocks: [block({ id: 'inner', value: scoped('mobile') })]
|
|
143
|
+
})
|
|
144
|
+
])
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
expect(compositionUsesScopedLineBreaks(sections)).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('finds one authored into a block property rather than its value', () => {
|
|
151
|
+
const sections = [
|
|
152
|
+
section([block({ properties: { content: scoped('tablet') } })])
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
expect(compositionUsesScopedLineBreaks(sections)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('says no for a plain <br>', () => {
|
|
159
|
+
const sections = [
|
|
160
|
+
section([block({ value: 'Şehrin ritmiyle buluşan<br> güneşli' })])
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
expect(compositionUsesScopedLineBreaks(sections)).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('survives a section with no blocks array at all', () => {
|
|
167
|
+
const sections = [section(undefined as any)];
|
|
168
|
+
|
|
169
|
+
expect(() => compositionUsesScopedLineBreaks(sections)).not.toThrow();
|
|
170
|
+
expect(compositionUsesScopedLineBreaks(sections)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('generateThemeCSS', () => {
|
|
175
|
+
it('emits the sheet once, ahead of the section rules', () => {
|
|
176
|
+
const sections = [
|
|
177
|
+
section([block({ value: scoped('mobile tablet') })], {
|
|
178
|
+
styles: { 'background-color': { desktop: '#fff' } }
|
|
179
|
+
})
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const css = generateThemeCSS(sections);
|
|
183
|
+
const breakAt = css.indexOf(`br[${LINE_BREAK_ATTRIBUTE}]`);
|
|
184
|
+
const sectionAt = css.indexOf('[data-section-id=');
|
|
185
|
+
|
|
186
|
+
expect(breakAt).toBeGreaterThanOrEqual(0);
|
|
187
|
+
expect(sectionAt).toBeGreaterThan(breakAt);
|
|
188
|
+
expect(
|
|
189
|
+
css.match(new RegExp(`br\\[${LINE_BREAK_ATTRIBUTE}\\]\\{`, 'g'))
|
|
190
|
+
).toHaveLength(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('emits NOTHING new for a composition that uses no scoped break', () => {
|
|
194
|
+
const sections = [
|
|
195
|
+
section([block({ value: 'plain<br>text' })], {
|
|
196
|
+
styles: { 'background-color': { desktop: '#fff' } }
|
|
197
|
+
})
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const css = generateThemeCSS(sections);
|
|
201
|
+
|
|
202
|
+
expect(css).not.toContain(LINE_BREAK_ATTRIBUTE);
|
|
203
|
+
expect(css).toContain('[data-section-id=');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('passes the canvas breakpoint through', () => {
|
|
207
|
+
const sections = [section([block({ value: scoped('desktop') })])];
|
|
208
|
+
|
|
209
|
+
const css = generateThemeCSS(sections, 'mobile');
|
|
210
|
+
|
|
211
|
+
expect(css).toContain(`br[${LINE_BREAK_ATTRIBUTE}~="mobile"]`);
|
|
212
|
+
expect(css).not.toContain('@media');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
package/src/utils/index.ts
CHANGED
|
@@ -33,6 +33,10 @@ import {
|
|
|
33
33
|
splitSectionContentWidth
|
|
34
34
|
} from './section-content-width';
|
|
35
35
|
import { hasCustomSectionRenderer } from '../sections/custom-renderer-section-types';
|
|
36
|
+
import {
|
|
37
|
+
buildLineBreakCss,
|
|
38
|
+
compositionUsesScopedLineBreaks
|
|
39
|
+
} from './line-breaks';
|
|
36
40
|
|
|
37
41
|
export * from './publish-window';
|
|
38
42
|
export * from './part-styles';
|
|
@@ -1456,7 +1460,14 @@ export const generateThemeCSS = (
|
|
|
1456
1460
|
sections: Section[],
|
|
1457
1461
|
targetBreakpoint?: string
|
|
1458
1462
|
): string => {
|
|
1459
|
-
|
|
1463
|
+
// First, and outside every section selector: the rules are global by nature
|
|
1464
|
+
// (a `<br>` scoped to the phone means the same thing wherever it was typed),
|
|
1465
|
+
// and they must lose a specificity tie to anything a section or block emits
|
|
1466
|
+
// for itself, which source order settles. Emitted only when the composition
|
|
1467
|
+
// actually carries a scoped break — see utils/line-breaks.ts.
|
|
1468
|
+
let css = compositionUsesScopedLineBreaks(sections)
|
|
1469
|
+
? buildLineBreakCss(targetBreakpoint)
|
|
1470
|
+
: '';
|
|
1460
1471
|
|
|
1461
1472
|
sections.forEach((section) => {
|
|
1462
1473
|
const sectionSelector = `[data-section-id="${escapeCssAttributeValue(
|
|
@@ -457,6 +457,52 @@ const normalizeCurrencyLabel = (rawCurrency: unknown): string => {
|
|
|
457
457
|
return normalized;
|
|
458
458
|
};
|
|
459
459
|
|
|
460
|
+
const parseProductPrice = (value: unknown): number => {
|
|
461
|
+
const parsed = Number(value);
|
|
462
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* What a row costs, from either payload shape.
|
|
467
|
+
*
|
|
468
|
+
* v2 collection items publish prices under `active_price`; PDP-shaped
|
|
469
|
+
* payloads carry them at the top level. Everything that prints a row's price
|
|
470
|
+
* — the slider's own bindings and the quick view panel — has to give the same
|
|
471
|
+
* answer, so the reader lives here beside the formatter rather than once per
|
|
472
|
+
* caller. A missing figure reads as 0, which callers treat as "do not print".
|
|
473
|
+
*/
|
|
474
|
+
export const readProductPricePair = (
|
|
475
|
+
productData: Record<string, unknown> | null | undefined
|
|
476
|
+
): { current: number; retail: number } => {
|
|
477
|
+
const activePrice = (productData as any)?.active_price as
|
|
478
|
+
| Record<string, unknown>
|
|
479
|
+
| undefined;
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
current: parseProductPrice(
|
|
483
|
+
activePrice?.price ?? (productData as any)?.price
|
|
484
|
+
),
|
|
485
|
+
retail: parseProductPrice(
|
|
486
|
+
activePrice?.retail_price ?? (productData as any)?.retail_price
|
|
487
|
+
)
|
|
488
|
+
};
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
/** The currency the row prints in, from either payload shape. */
|
|
492
|
+
export const readProductCurrency = (
|
|
493
|
+
productData: Record<string, unknown> | null | undefined
|
|
494
|
+
): unknown => {
|
|
495
|
+
const activePrice = (productData as any)?.active_price as
|
|
496
|
+
| Record<string, unknown>
|
|
497
|
+
| undefined;
|
|
498
|
+
|
|
499
|
+
return (
|
|
500
|
+
activePrice?.currency_type ??
|
|
501
|
+
(productData as any)?.currency_type ??
|
|
502
|
+
(productData as any)?.currency
|
|
503
|
+
);
|
|
504
|
+
};
|
|
505
|
+
|
|
460
506
|
export const getPriceWithCurrency = (
|
|
461
507
|
value: unknown,
|
|
462
508
|
currency: unknown
|