@akinon/pz-theme 2.0.143-beta.0 → 2.0.143
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 +5 -4
- package/package.json +4 -4
- package/src/__tests__/designer-live-rows.test.tsx +257 -0
- package/src/blocks/__tests__/favorite-button-icons.test.tsx +102 -0
- package/src/blocks/__tests__/listing-price-note.test.ts +47 -0
- package/src/blocks/favorite-button-block.tsx +36 -15
- package/src/blocks/group-block.tsx +52 -1
- package/src/blocks/image-block.tsx +26 -2
- package/src/blocks/listing-context.ts +5 -2
- package/src/blocks/listing-row.ts +64 -4
- package/src/blocks/product-grid-block.tsx +83 -3
- package/src/components/card-list-slider.tsx +374 -0
- package/src/components/card-list-views.tsx +278 -0
- package/src/components/card-zoom.tsx +53 -0
- package/src/components/with-designer-features.tsx +18 -0
- package/src/theme-block.tsx +11 -0
- package/src/theme-placeholder-wrapper.tsx +52 -7
- package/src/utils/__tests__/card-media.test.tsx +789 -0
- package/src/utils/__tests__/card-views.test.tsx +332 -0
- package/src/utils/__tests__/swatch-colors.test.ts +145 -0
- package/src/utils/card-media.ts +331 -0
- package/src/utils/iterator-utils.ts +10 -3
- package/src/utils/live-rows.ts +169 -34
- package/src/utils/property-switches.ts +248 -2
- package/src/utils/swatch-colors.ts +81 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product card list settings (ZERO-5173): the list of cards as a slider, how
|
|
3
|
+
* many cards it shows at once, and the card picture zooming in on hover.
|
|
4
|
+
*
|
|
5
|
+
* WHERE THE SETTINGS LIVE. On the same levels as the card's switches
|
|
6
|
+
* (property-switches.ts), nearest definer wins:
|
|
7
|
+
*
|
|
8
|
+
* - the SLIDER belongs to the list — the product grid, or the Product Cards
|
|
9
|
+
* section around its repeated cards — because only the list can lay its
|
|
10
|
+
* cards out in one row. The grid reads its own properties; a section's
|
|
11
|
+
* settings reach the generated repeat that holds its cards through the
|
|
12
|
+
* property-switch pass, which writes them as `cardsSlider`. Its title
|
|
13
|
+
* (CardsSliderTitle) travels with them.
|
|
14
|
+
* - the ZOOM belongs to the card's picture, the image bound to `item.image`:
|
|
15
|
+
* the list switches it for every card, a card may say otherwise. Every
|
|
16
|
+
* Product Card ever inserted carries that binding, so a published card
|
|
17
|
+
* gains the setting without being re-inserted.
|
|
18
|
+
*
|
|
19
|
+
* Nothing authored means nothing written onto any block, so an unconfigured
|
|
20
|
+
* list renders exactly what it rendered before.
|
|
21
|
+
*
|
|
22
|
+
* Pure: the pass and the renderers read from here, and none of them is
|
|
23
|
+
* imported back.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const toText = (value: unknown): string =>
|
|
27
|
+
typeof value === 'string' ? value.trim() : '';
|
|
28
|
+
|
|
29
|
+
/** Keyed `cards*` so a block's own settings up the chain are never mistaken for them. */
|
|
30
|
+
export const CARD_LIST_PROPERTIES = {
|
|
31
|
+
slider: 'cardsSlider',
|
|
32
|
+
// The slider's title, carried the way the GANT rail (the flash deals
|
|
33
|
+
// slider) carries its own: the text, on or off, and its type — see
|
|
34
|
+
// CardsSliderTitle.
|
|
35
|
+
sliderShowTitle: 'cardsSliderShowTitle',
|
|
36
|
+
sliderTitle: 'cardsSliderTitle',
|
|
37
|
+
sliderTitleColor: 'cardsSliderTitleColor',
|
|
38
|
+
sliderTitleSize: 'cardsSliderTitleSize',
|
|
39
|
+
sliderTitleWeight: 'cardsSliderTitleWeight',
|
|
40
|
+
sliderTitleFont: 'cardsSliderTitleFont',
|
|
41
|
+
sliderTitleTransform: 'cardsSliderTitleTransform',
|
|
42
|
+
perView: 'cardsPerView',
|
|
43
|
+
perViewMobile: 'cardsPerViewMobile',
|
|
44
|
+
sliderArrows: 'cardsSliderArrows',
|
|
45
|
+
// The listing's grid views — see CardView: the view the list is drawn in,
|
|
46
|
+
// whether the shopper gets the toolbar's toggles, and the toggles' look.
|
|
47
|
+
view: 'cardsView',
|
|
48
|
+
viewModes: 'cardsViewModes',
|
|
49
|
+
viewTogglesAlign: 'cardsViewTogglesAlign',
|
|
50
|
+
viewTogglesColor: 'cardsViewTogglesColor',
|
|
51
|
+
viewTogglesSize: 'cardsViewTogglesSize',
|
|
52
|
+
viewTogglesGap: 'cardsViewTogglesGap',
|
|
53
|
+
viewTogglesPadding: 'cardsViewTogglesPadding',
|
|
54
|
+
hoverZoom: 'cardImageHoverZoom'
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The GANT listing's grid views (gant.co.uk, `utils/plp/grid-mode.ts` there)
|
|
59
|
+
* for a card list that is not sliding: catalogue — three across, one below
|
|
60
|
+
* 1024px; classic — the list's own grid (four across, two below), the
|
|
61
|
+
* default; archive — eight across, four below, pictures only.
|
|
62
|
+
*
|
|
63
|
+
* The designer picks the view the list is drawn in (`cardsView`). With the
|
|
64
|
+
* toolbar's toggles on (`cardsViewModes`) the shopper switches it, and the
|
|
65
|
+
* switch lives in the URL hash (`#catalogue` / `#classic` / `#archive`) as
|
|
66
|
+
* the listing's does, so a link opens the view it was shared in.
|
|
67
|
+
*/
|
|
68
|
+
export type CardView = 'catalogue' | 'classic' | 'archive';
|
|
69
|
+
|
|
70
|
+
export const CARD_VIEWS: CardView[] = ['catalogue', 'classic', 'archive'];
|
|
71
|
+
|
|
72
|
+
export const DEFAULT_CARD_VIEW: CardView = 'classic';
|
|
73
|
+
|
|
74
|
+
/** Columns per view from 1024px up and below; classic keeps the list's grid. */
|
|
75
|
+
export const CARD_VIEW_COLUMNS: Record<
|
|
76
|
+
Exclude<CardView, 'classic'>,
|
|
77
|
+
{ desktop: number; mobile: number }
|
|
78
|
+
> = {
|
|
79
|
+
catalogue: { desktop: 3, mobile: 1 },
|
|
80
|
+
archive: { desktop: 8, mobile: 4 }
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What a part of the card says about the views: the Product Card's caption is
|
|
85
|
+
* `hideInCardView: 'archive'`, so the archive shows pictures only. Written on
|
|
86
|
+
* the block's wrapper, where its box is, so a hidden caption leaves no padding.
|
|
87
|
+
*/
|
|
88
|
+
export const HIDE_IN_CARD_VIEW_PROPERTY = 'hideInCardView';
|
|
89
|
+
export const HIDE_IN_CARD_VIEW_ATTRIBUTE = 'data-hide-in-card-view';
|
|
90
|
+
|
|
91
|
+
export const isCardView = (value: unknown): value is CardView =>
|
|
92
|
+
typeof value === 'string' && (CARD_VIEWS as string[]).includes(value);
|
|
93
|
+
|
|
94
|
+
/** The view a hash names, or null — the list's own view then. */
|
|
95
|
+
export const cardViewFromHash = (
|
|
96
|
+
hash: string | null | undefined
|
|
97
|
+
): CardView | null => {
|
|
98
|
+
const value = (hash ?? '').replace(/^#/, '').toLowerCase();
|
|
99
|
+
return isCardView(value) ? value : null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type CardViewTogglesAlign = 'left' | 'center' | 'right';
|
|
103
|
+
|
|
104
|
+
/** The toggles' row, as the listing's toolbar draws it by default. */
|
|
105
|
+
export interface CardViewToggles {
|
|
106
|
+
align: CardViewTogglesAlign;
|
|
107
|
+
color: string;
|
|
108
|
+
/** The glyph, pixels; the button is 8px wider (44px squares below 1024px). */
|
|
109
|
+
size: number;
|
|
110
|
+
/** Between the buttons from 1024px up; flush below, as on the listing. */
|
|
111
|
+
gap: number;
|
|
112
|
+
/** The row's padding, a CSS shorthand of plain lengths. */
|
|
113
|
+
padding: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const DEFAULT_CARD_VIEW_TOGGLES: CardViewToggles = {
|
|
117
|
+
align: 'right',
|
|
118
|
+
color: '#000000',
|
|
119
|
+
size: 24,
|
|
120
|
+
gap: 6,
|
|
121
|
+
padding: '6px 14px 6px 24px'
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** The list's views: the one it is drawn in, and the toggles when offered. */
|
|
125
|
+
export interface CardViewSettings {
|
|
126
|
+
view: CardView;
|
|
127
|
+
toggles?: CardViewToggles;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const readCardView = (raw: unknown): CardView | undefined =>
|
|
131
|
+
isCardView(toText(raw)) ? (toText(raw) as CardView) : undefined;
|
|
132
|
+
|
|
133
|
+
export const readCardViewTogglesAlign = (
|
|
134
|
+
raw: unknown
|
|
135
|
+
): CardViewTogglesAlign | undefined => {
|
|
136
|
+
const text = toText(raw);
|
|
137
|
+
return text === 'left' || text === 'center' || text === 'right'
|
|
138
|
+
? text
|
|
139
|
+
: undefined;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const readPixels = (raw: unknown, min: number, max: number) => {
|
|
143
|
+
if (raw === undefined || raw === null || raw === '') return undefined;
|
|
144
|
+
const value = parseFloat(String(raw));
|
|
145
|
+
return Number.isFinite(value) && value >= min && value <= max
|
|
146
|
+
? value
|
|
147
|
+
: undefined;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const readCardViewTogglesSize = (raw: unknown): number | undefined =>
|
|
151
|
+
readPixels(raw, 12, 48);
|
|
152
|
+
|
|
153
|
+
export const readCardViewTogglesGap = (raw: unknown): number | undefined =>
|
|
154
|
+
readPixels(raw, 0, 48);
|
|
155
|
+
|
|
156
|
+
/** One to four plain lengths — it lands in a style declaration. */
|
|
157
|
+
export const readCardViewTogglesPadding = (
|
|
158
|
+
raw: unknown
|
|
159
|
+
): string | undefined => {
|
|
160
|
+
const text = toText(raw);
|
|
161
|
+
return /^(\d+(\.\d+)?(px|rem|em|%)?)(\s+\d+(\.\d+)?(px|rem|em|%)?){0,3}$/.test(
|
|
162
|
+
text
|
|
163
|
+
)
|
|
164
|
+
? text
|
|
165
|
+
.split(/\s+/)
|
|
166
|
+
.map((part) => (/^\d+(\.\d+)?$/.test(part) ? `${part}px` : part))
|
|
167
|
+
.join(' ')
|
|
168
|
+
: undefined;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export type CardsSliderTitleTransform = 'uppercase' | 'none' | 'capitalize';
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The title the slider draws on the left of its arrows. Settings of the list,
|
|
175
|
+
* beside its other "Cards:" settings, like the rail's own title settings — so
|
|
176
|
+
* a list that slides is titled from the same panel that made it slide, and
|
|
177
|
+
* there is no block to insert, find or lose. The canvas selects the list
|
|
178
|
+
* when the title is clicked.
|
|
179
|
+
*/
|
|
180
|
+
export interface CardsSliderTitle {
|
|
181
|
+
text: string;
|
|
182
|
+
color: string;
|
|
183
|
+
/** Pixels. */
|
|
184
|
+
size: number;
|
|
185
|
+
weight: string;
|
|
186
|
+
/** Empty = the theme's body font, the cards' own. */
|
|
187
|
+
font: string;
|
|
188
|
+
transform: CardsSliderTitleTransform;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The rail's title: regular 14px / 1.4, black, in capitals. */
|
|
192
|
+
export const DEFAULT_CARDS_SLIDER_TITLE: Omit<CardsSliderTitle, 'text'> = {
|
|
193
|
+
color: '#000000',
|
|
194
|
+
size: 14,
|
|
195
|
+
weight: '400',
|
|
196
|
+
font: '',
|
|
197
|
+
transform: 'uppercase'
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const TITLE_TRANSFORMS: CardsSliderTitleTransform[] = [
|
|
201
|
+
'uppercase',
|
|
202
|
+
'none',
|
|
203
|
+
'capitalize'
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
/** One colour value and nothing else — it lands in a style declaration. */
|
|
207
|
+
const PLAIN_COLOR =
|
|
208
|
+
/^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla)\([\d\s.,%/]+\)|[a-z]+)$/i;
|
|
209
|
+
|
|
210
|
+
/** The authored text; a non-string (an old on/off value) is no text at all. */
|
|
211
|
+
export const readCardsSliderTitleText = (raw: unknown): string | undefined =>
|
|
212
|
+
typeof raw === 'string' ? raw : undefined;
|
|
213
|
+
|
|
214
|
+
export const readCardsSliderTitleColor = (raw: unknown): string | undefined => {
|
|
215
|
+
const text = toText(raw);
|
|
216
|
+
return PLAIN_COLOR.test(text) ? text : undefined;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const readCardsSliderTitleSize = (raw: unknown): number | undefined => {
|
|
220
|
+
if (raw === undefined || raw === null || raw === '') return undefined;
|
|
221
|
+
const size = parseFloat(String(raw));
|
|
222
|
+
return Number.isFinite(size) && size >= 8 && size <= 96 ? size : undefined;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const readCardsSliderTitleWeight = (
|
|
226
|
+
raw: unknown
|
|
227
|
+
): string | undefined => {
|
|
228
|
+
const text = toText(raw);
|
|
229
|
+
return /^[1-9]00$/.test(text) ? text : undefined;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** A font stack; anything that could end the declaration it lands in is refused. */
|
|
233
|
+
export const readCardsSliderTitleFont = (raw: unknown): string | undefined => {
|
|
234
|
+
const text = toText(raw);
|
|
235
|
+
return text && text.length <= 200 && !/[;{}<>]/.test(text) ? text : undefined;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const readCardsSliderTitleTransform = (
|
|
239
|
+
raw: unknown
|
|
240
|
+
): CardsSliderTitleTransform | undefined => {
|
|
241
|
+
const text = toText(raw);
|
|
242
|
+
return (TITLE_TRANSFORMS as string[]).includes(text)
|
|
243
|
+
? (text as CardsSliderTitleTransform)
|
|
244
|
+
: undefined;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* `top-right` is the GANT rail's placement — the two chevrons on the right of
|
|
249
|
+
* the row above the cards, where a title sits on the left — and the default;
|
|
250
|
+
* `sides` centres one on either edge of the cards; `none` leaves the row to
|
|
251
|
+
* the swipe.
|
|
252
|
+
*/
|
|
253
|
+
export type CardsSliderArrows = 'top-right' | 'sides' | 'none';
|
|
254
|
+
|
|
255
|
+
export const CARDS_SLIDER_ARROW_POSITIONS: CardsSliderArrows[] = [
|
|
256
|
+
'top-right',
|
|
257
|
+
'sides',
|
|
258
|
+
'none'
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
/** Cards in view at once, desktop (1024px and up) and below it. */
|
|
262
|
+
export const DEFAULT_CARDS_PER_VIEW = 4;
|
|
263
|
+
export const DEFAULT_CARDS_PER_VIEW_MOBILE = 2;
|
|
264
|
+
export const MAX_CARDS_PER_VIEW = 6;
|
|
265
|
+
export const MAX_CARDS_PER_VIEW_MOBILE = 3;
|
|
266
|
+
|
|
267
|
+
export interface CardsSliderSettings {
|
|
268
|
+
perView: number;
|
|
269
|
+
perViewMobile: number;
|
|
270
|
+
arrows: CardsSliderArrows;
|
|
271
|
+
/** Present only while the list shows a title: switched on, with text. */
|
|
272
|
+
title?: CardsSliderTitle;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** `{{item.image}}` alone means the bare path, as it does for the iterator. */
|
|
276
|
+
const normalizeBinding = (value: unknown): string => {
|
|
277
|
+
const text = toText(value);
|
|
278
|
+
const single = text.match(/^\{\{\s*([^{}]+?)\s*\}\}$/);
|
|
279
|
+
return single ? single[1].trim() : text;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The card's picture: the image block bound to the row's main image. Not a
|
|
284
|
+
* flag a template sets — the binding is what makes the block the product's
|
|
285
|
+
* picture, and it is what the cards inserted before this existed carry.
|
|
286
|
+
*/
|
|
287
|
+
export const isCardPictureBlock = (block: {
|
|
288
|
+
type?: string;
|
|
289
|
+
properties?: Record<string, unknown> | null;
|
|
290
|
+
}): boolean =>
|
|
291
|
+
block?.type === 'image' &&
|
|
292
|
+
normalizeBinding(block.properties?.dataBinding) === 'item.image';
|
|
293
|
+
|
|
294
|
+
/** A count as authored, capped, or undefined when nothing usable was. */
|
|
295
|
+
export const readCardsPerView = (
|
|
296
|
+
raw: unknown,
|
|
297
|
+
max: number
|
|
298
|
+
): number | undefined => {
|
|
299
|
+
if (raw === undefined || raw === null || raw === '') return undefined;
|
|
300
|
+
|
|
301
|
+
const numeric = Math.round(Number(raw));
|
|
302
|
+
if (!Number.isFinite(numeric) || numeric < 1) return undefined;
|
|
303
|
+
|
|
304
|
+
return Math.min(numeric, max);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
export const readCardsSliderArrows = (
|
|
308
|
+
raw: unknown
|
|
309
|
+
): CardsSliderArrows | undefined => {
|
|
310
|
+
const text = toText(raw);
|
|
311
|
+
return (CARDS_SLIDER_ARROW_POSITIONS as string[]).includes(text)
|
|
312
|
+
? (text as CardsSliderArrows)
|
|
313
|
+
: undefined;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Hover rules for the zoom, injected once per document by the renderer. Only
|
|
318
|
+
* where a pointer can hover, so a tap on a phone never leaves a picture stuck
|
|
319
|
+
* enlarged.
|
|
320
|
+
*/
|
|
321
|
+
export const CARD_ZOOM_STYLE_ID = 'pz-card-zoom';
|
|
322
|
+
|
|
323
|
+
export const CARD_ZOOM_CSS =
|
|
324
|
+
'@media (hover:hover){' +
|
|
325
|
+
'[data-card-zoom] img{transition:transform .5s ease}' +
|
|
326
|
+
'[data-card-zoom]:hover img{transform:scale(var(--card-zoom-scale,1.08))}' +
|
|
327
|
+
'}' +
|
|
328
|
+
'@media (prefers-reduced-motion:reduce){[data-card-zoom] img{transition:none}}';
|
|
329
|
+
|
|
330
|
+
/** The toggles' colour: one colour value, as for the slider title. */
|
|
331
|
+
export const readCardViewTogglesColor = readCardsSliderTitleColor;
|
|
@@ -1504,7 +1504,12 @@ export const readRowLookups = (
|
|
|
1504
1504
|
export const normalizeCollectionRow = (
|
|
1505
1505
|
item: Record<string, unknown>,
|
|
1506
1506
|
index: number,
|
|
1507
|
-
options: {
|
|
1507
|
+
options: {
|
|
1508
|
+
lookups?: ListingValueLookups;
|
|
1509
|
+
format?: RowMoneyFormat;
|
|
1510
|
+
/** The repeat's rows, for a brand's swatch colours (utils/swatch-colors). */
|
|
1511
|
+
rows?: unknown[];
|
|
1512
|
+
} = {}
|
|
1508
1513
|
): Record<string, unknown> => {
|
|
1509
1514
|
if (!isRecord(item) || Object.keys(item).length === 0) return item;
|
|
1510
1515
|
|
|
@@ -1525,7 +1530,8 @@ export const normalizeCollectionRow = (
|
|
|
1525
1530
|
currency: typeof currency === 'string' ? currency : undefined,
|
|
1526
1531
|
locale: options.format?.locale,
|
|
1527
1532
|
money: options.format?.money,
|
|
1528
|
-
lookups: options.lookups
|
|
1533
|
+
lookups: options.lookups,
|
|
1534
|
+
rows: options.rows
|
|
1529
1535
|
});
|
|
1530
1536
|
if (!normalized) return flat;
|
|
1531
1537
|
|
|
@@ -1688,7 +1694,8 @@ export const buildIteratorBlock = ({
|
|
|
1688
1694
|
const item = scopesProducts
|
|
1689
1695
|
? normalizeCollectionRow(rawItem as Record<string, unknown>, index, {
|
|
1690
1696
|
lookups: rowLookups,
|
|
1691
|
-
format: rowFormat
|
|
1697
|
+
format: rowFormat,
|
|
1698
|
+
rows: displayItems
|
|
1692
1699
|
})
|
|
1693
1700
|
: rawItem;
|
|
1694
1701
|
|
package/src/utils/live-rows.ts
CHANGED
|
@@ -16,13 +16,19 @@ import { buildClientRequestUrl } from '@akinon/next/utils';
|
|
|
16
16
|
* live rows at all: only while the editor reports the section's pick as
|
|
17
17
|
* saved (`dataSourceSaved`). Until an unsaved pick is published, the section's
|
|
18
18
|
* widget is still bound to the previous collection, so its live answer would
|
|
19
|
-
* be the wrong rows
|
|
19
|
+
* be the wrong rows. An unsaved pick shows the rows of the collection's
|
|
20
|
+
* PREVIEW WIDGET instead, when the editor prepared one (see
|
|
21
|
+
* `collectionPreviewWidget`), and the picked snapshot otherwise.
|
|
20
22
|
*
|
|
21
23
|
* Client-safe: no server-only imports. The server loader imports
|
|
22
24
|
* `carriesProducts` from here so both sides apply one acceptance rule.
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Rows kept for the designer canvas: a section's own answer per
|
|
29
|
+
* `<sectionId>|<bindingKey>`, a preview widget's per its slug (which holds no
|
|
30
|
+
* `|`, so the two can never collide).
|
|
31
|
+
*/
|
|
26
32
|
export type LiveRowsStore = Map<string, unknown>;
|
|
27
33
|
|
|
28
34
|
/**
|
|
@@ -71,6 +77,68 @@ export const collectionBindingKey = (dataSource: unknown): string | null => {
|
|
|
71
77
|
export const liveRowsEntryKey = (sectionId: string, bindingKey: string) =>
|
|
72
78
|
`${sectionId}|${bindingKey}`;
|
|
73
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The hidden widget the theme editor keeps bound to one collection binding,
|
|
82
|
+
* so an UNSAVED pick can be shown with the rows the channel will give it once
|
|
83
|
+
* published (pz-dynamic-widget services/collection-preview-widget.ts builds
|
|
84
|
+
* the same slug — keep the two in step).
|
|
85
|
+
*
|
|
86
|
+
* WHY IT EXISTS. Before publish nothing else answers with the channel's rows
|
|
87
|
+
* for a collection: the section's widget is still bound to the previous one,
|
|
88
|
+
* the storefront lists a collection only through a widget, and the editor's
|
|
89
|
+
* snapshot comes from Omnitron's item list — the catalogue's rows, out-of-stock
|
|
90
|
+
* variants and old prices included, in its own order. So an unsaved pick
|
|
91
|
+
* showed different products from the ones the section showed once published.
|
|
92
|
+
* The preview widget is bound exactly as publishing binds the section
|
|
93
|
+
* (`product_collection` and `product_limit`) and sits in no placeholder, so no
|
|
94
|
+
* page ever renders it.
|
|
95
|
+
*/
|
|
96
|
+
export const COLLECTION_PREVIEW_WIDGET_PREFIX = 'theme-editor-collection-';
|
|
97
|
+
|
|
98
|
+
const POSITIVE_INTEGER = /^[1-9]\d*$/;
|
|
99
|
+
|
|
100
|
+
const integerText = (value: unknown): string | null => {
|
|
101
|
+
const text =
|
|
102
|
+
typeof value === 'number' || typeof value === 'string' ? String(value) : '';
|
|
103
|
+
return POSITIVE_INTEGER.test(text) ? text : null;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `theme-editor-collection-<pk>-<productLimit>`: one widget per binding, never
|
|
108
|
+
* re-pointed, so its slug alone says what it answers for. `null` for anything
|
|
109
|
+
* that is not a positive integer pair.
|
|
110
|
+
*/
|
|
111
|
+
export const collectionPreviewWidgetSlug = (
|
|
112
|
+
pk: unknown,
|
|
113
|
+
productLimit: unknown
|
|
114
|
+
): string | null => {
|
|
115
|
+
const pkText = integerText(pk);
|
|
116
|
+
const limitText = integerText(productLimit);
|
|
117
|
+
|
|
118
|
+
return pkText && limitText
|
|
119
|
+
? `${COLLECTION_PREVIEW_WIDGET_PREFIX}${pkText}-${limitText}`
|
|
120
|
+
: null;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The preview widget the canvas may ask for this source: the slug the editor
|
|
125
|
+
* stamped on it (`collection.previewWidget`) once that widget was in place,
|
|
126
|
+
* and only while it still names the source's current binding. A record
|
|
127
|
+
* re-pointed by anything that prepared no widget keeps the stamp of the
|
|
128
|
+
* binding it left, and that widget's rows are another collection's.
|
|
129
|
+
*/
|
|
130
|
+
export const collectionPreviewWidget = (dataSource: unknown): string | null => {
|
|
131
|
+
const collection = readCollection(dataSource);
|
|
132
|
+
if (!collection) return null;
|
|
133
|
+
|
|
134
|
+
const slug = collectionPreviewWidgetSlug(
|
|
135
|
+
collection.pk,
|
|
136
|
+
collection.productLimit
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return slug && collection.previewWidget === slug ? slug : null;
|
|
140
|
+
};
|
|
141
|
+
|
|
74
142
|
export interface LiveRowsRequest {
|
|
75
143
|
path: string;
|
|
76
144
|
/**
|
|
@@ -114,42 +182,81 @@ export const liveRowsPaths = (
|
|
|
114
182
|
return paths;
|
|
115
183
|
};
|
|
116
184
|
|
|
185
|
+
/**
|
|
186
|
+
* One endpoint's answer, or `null` when it gives none that is usable — also
|
|
187
|
+
* after `timeoutMs`, when one is given. Cache-busted, so an answer asked for
|
|
188
|
+
* right after a write is not a copy from before it. Never rejects.
|
|
189
|
+
*/
|
|
190
|
+
const askWidget = async (
|
|
191
|
+
{ path, requireProducts }: LiveRowsRequest,
|
|
192
|
+
timeoutMs?: number
|
|
193
|
+
): Promise<unknown | null> => {
|
|
194
|
+
let answer: unknown = null;
|
|
195
|
+
const controller =
|
|
196
|
+
timeoutMs && typeof AbortController !== 'undefined'
|
|
197
|
+
? new AbortController()
|
|
198
|
+
: null;
|
|
199
|
+
const timer = controller
|
|
200
|
+
? setTimeout(() => controller.abort(), timeoutMs)
|
|
201
|
+
: null;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const response = await fetch(
|
|
205
|
+
buildClientRequestUrl(path, { cache: false }),
|
|
206
|
+
controller ? { signal: controller.signal } : undefined
|
|
207
|
+
);
|
|
208
|
+
if (response.ok) {
|
|
209
|
+
answer = await response.json();
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
answer = null;
|
|
213
|
+
} finally {
|
|
214
|
+
if (timer) clearTimeout(timer);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!answer || typeof answer !== 'object') return null;
|
|
218
|
+
if (requireProducts && !carriesProducts(answer)) return null;
|
|
219
|
+
|
|
220
|
+
return answer;
|
|
221
|
+
};
|
|
222
|
+
|
|
117
223
|
/**
|
|
118
224
|
* Fetches a section's live rows from the browser, walking `liveRowsPaths` in
|
|
119
|
-
* order.
|
|
120
|
-
*
|
|
121
|
-
* never rejects.
|
|
225
|
+
* order. Resolves `null` when no endpoint gives a usable answer; never
|
|
226
|
+
* rejects.
|
|
122
227
|
*/
|
|
123
228
|
export const fetchDesignerLiveRows = async (
|
|
124
229
|
sectionId: string,
|
|
125
230
|
collection: { slug?: unknown } | null | undefined
|
|
126
231
|
): Promise<unknown | null> => {
|
|
127
|
-
for (const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
)) {
|
|
131
|
-
let answer: unknown = null;
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
const response = await fetch(
|
|
135
|
-
buildClientRequestUrl(path, { cache: false })
|
|
136
|
-
);
|
|
137
|
-
if (response.ok) {
|
|
138
|
-
answer = await response.json();
|
|
139
|
-
}
|
|
140
|
-
} catch {
|
|
141
|
-
answer = null;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (!answer || typeof answer !== 'object') continue;
|
|
145
|
-
if (requireProducts && !carriesProducts(answer)) continue;
|
|
146
|
-
|
|
147
|
-
return answer;
|
|
232
|
+
for (const request of liveRowsPaths(sectionId, collection)) {
|
|
233
|
+
const answer = await askWidget(request);
|
|
234
|
+
if (answer !== null) return answer;
|
|
148
235
|
}
|
|
149
236
|
|
|
150
237
|
return null;
|
|
151
238
|
};
|
|
152
239
|
|
|
240
|
+
/**
|
|
241
|
+
* How long the canvas waits for a preview widget before it lets the snapshot
|
|
242
|
+
* through: the section holds its previous rows meanwhile (see
|
|
243
|
+
* `hangDesignerDataSource`), and must not hold them forever.
|
|
244
|
+
*/
|
|
245
|
+
export const PREVIEW_ROWS_TIMEOUT_MS = 8000;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A preview widget's rows (`collectionPreviewWidget`), taken only when it
|
|
249
|
+
* carries products: a collection the channel serves nothing from leaves the
|
|
250
|
+
* canvas on the snapshot, which is also what the storefront falls back to.
|
|
251
|
+
*/
|
|
252
|
+
export const fetchDesignerPreviewRows = (
|
|
253
|
+
previewWidget: string
|
|
254
|
+
): Promise<unknown | null> =>
|
|
255
|
+
askWidget(
|
|
256
|
+
{ path: `/widgets/${previewWidget}/`, requireProducts: true },
|
|
257
|
+
PREVIEW_ROWS_TIMEOUT_MS
|
|
258
|
+
);
|
|
259
|
+
|
|
153
260
|
/**
|
|
154
261
|
* The live rows the server already wrote into the document, keyed by the
|
|
155
262
|
* binding the server resolved them for. Seeding from these is what lets a
|
|
@@ -177,11 +284,23 @@ export const seedLiveRows = (
|
|
|
177
284
|
* The data source the designer canvas renders a section with.
|
|
178
285
|
*
|
|
179
286
|
* `raw` is the source record as the editor (or theme-config) holds it. Its
|
|
180
|
-
* `details.collection.data` is dropped, and live rows are put back only
|
|
181
|
-
* the
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
287
|
+
* `details.collection.data` is dropped, and live rows are put back only for
|
|
288
|
+
* the binding the record names now:
|
|
289
|
+
*
|
|
290
|
+
* - the section's own answer, when the editor reports the pick as saved;
|
|
291
|
+
* - otherwise the rows of the binding's preview widget, when the record names
|
|
292
|
+
* one and they are held. They are the section's rows-to-be before publish,
|
|
293
|
+
* and the same rows as its own answer after, until that answer is in.
|
|
294
|
+
*
|
|
295
|
+
* WHILE AN UNSAVED PICK'S PREVIEW ROWS ARE ON THEIR WAY, a caller that passes
|
|
296
|
+
* `hold` gets back `hold.previous` — what the section was drawn with until now
|
|
297
|
+
* (nothing, for a section that had no source) — so a pick changes the
|
|
298
|
+
* products once, straight to the right ones, instead of showing the snapshot
|
|
299
|
+
* first and swapping. A preview widget that gave no rows is held as `null`,
|
|
300
|
+
* which lets the snapshot through.
|
|
301
|
+
*
|
|
302
|
+
* A late answer for a binding the section has since left is stored under that
|
|
303
|
+
* binding's key, so it can never be attached to the new one.
|
|
185
304
|
*
|
|
186
305
|
* A record that carries no snapshot keeps the `data` it came with: there is
|
|
187
306
|
* nothing to protect, and those rows are what the canvas showed before and
|
|
@@ -190,16 +309,32 @@ export const seedLiveRows = (
|
|
|
190
309
|
export const hangDesignerDataSource = (
|
|
191
310
|
section: { id?: string; dataSourceSaved?: unknown } | null | undefined,
|
|
192
311
|
raw: any,
|
|
193
|
-
liveRows: LiveRowsStore
|
|
312
|
+
liveRows: LiveRowsStore,
|
|
313
|
+
hold?: { previous: unknown }
|
|
194
314
|
): any => {
|
|
195
315
|
const collection = readCollection(raw);
|
|
196
316
|
if (!collection) return raw;
|
|
197
317
|
|
|
318
|
+
const saved = section?.dataSourceSaved === true;
|
|
198
319
|
const bindingKey = collectionBindingKey(raw);
|
|
199
|
-
const
|
|
200
|
-
|
|
320
|
+
const sectionRows =
|
|
321
|
+
saved && section?.id && bindingKey
|
|
201
322
|
? liveRows.get(liveRowsEntryKey(section.id, bindingKey))
|
|
202
323
|
: undefined;
|
|
324
|
+
const previewWidget = collectionPreviewWidget(raw);
|
|
325
|
+
// `undefined` until the preview widget answered, `null` if it gave nothing.
|
|
326
|
+
const previewRows = previewWidget ? liveRows.get(previewWidget) : undefined;
|
|
327
|
+
const rows = sectionRows !== undefined ? sectionRows : previewRows ?? undefined;
|
|
328
|
+
|
|
329
|
+
if (
|
|
330
|
+
rows === undefined &&
|
|
331
|
+
hold &&
|
|
332
|
+
!saved &&
|
|
333
|
+
previewWidget &&
|
|
334
|
+
previewRows === undefined
|
|
335
|
+
) {
|
|
336
|
+
return hold.previous;
|
|
337
|
+
}
|
|
203
338
|
|
|
204
339
|
if (rows !== undefined) {
|
|
205
340
|
return {
|