@akinon/pz-theme 2.0.37-beta.0 → 2.0.37
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 +4 -4
- package/package.json +4 -4
- package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
- package/src/__tests__/custom-css.test.ts +181 -0
- package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
- package/src/__tests__/facet-filters-block.test.tsx +707 -0
- package/src/__tests__/item-template.test.tsx +184 -0
- package/src/__tests__/listing-controls.test.tsx +961 -0
- package/src/__tests__/part-style-scope.test.tsx +229 -0
- package/src/__tests__/part-styles.test.ts +311 -0
- package/src/__tests__/registry-parity.test.ts +42 -0
- package/src/__tests__/row-visibility.test.ts +672 -0
- package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
- package/src/__tests__/theme-block-html-id.test.tsx +167 -0
- package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
- package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
- package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
- package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
- package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
- package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
- package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
- package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
- package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
- package/src/blocks/__tests__/share-block.test.tsx +392 -0
- package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
- package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
- package/src/blocks/__tests__/variant-selector-golden.json +20 -0
- package/src/blocks/account-menu-block.tsx +264 -0
- package/src/blocks/add-to-cart-block.tsx +417 -0
- package/src/blocks/address-list-block.tsx +348 -0
- package/src/blocks/applied-filters-block.tsx +288 -0
- package/src/blocks/auth-form-block.tsx +295 -0
- package/src/blocks/basket-items-block.tsx +457 -0
- package/src/blocks/basket-summary-block.tsx +404 -0
- package/src/blocks/block-renderer-registry.tsx +94 -0
- package/src/blocks/change-password-form-block.tsx +240 -0
- package/src/blocks/checkout-steps-block.tsx +288 -0
- package/src/blocks/checkout-summary-block.tsx +449 -0
- package/src/blocks/countdown-block.tsx +182 -0
- package/src/blocks/coupon-list-block.tsx +292 -0
- package/src/blocks/facet-filters-block.tsx +1686 -0
- package/src/blocks/faq-accordion-block.tsx +358 -0
- package/src/blocks/favorite-button-block.tsx +252 -0
- package/src/blocks/favourites-grid-block.tsx +510 -0
- package/src/blocks/forgot-password-block.tsx +155 -0
- package/src/blocks/item-template-utils.ts +131 -0
- package/src/blocks/item-template.tsx +164 -0
- package/src/blocks/listing-breadcrumb-block.tsx +175 -0
- package/src/blocks/listing-context.ts +850 -0
- package/src/blocks/listing-pagination-block.tsx +923 -0
- package/src/blocks/listing-result-count-block.tsx +126 -0
- package/src/blocks/listing-title-block.tsx +115 -0
- package/src/blocks/oauth-buttons-block.tsx +205 -0
- package/src/blocks/order-detail-block.tsx +474 -0
- package/src/blocks/order-list-block.tsx +274 -0
- package/src/blocks/order-success-block.tsx +641 -0
- package/src/blocks/order-tracking-block.tsx +533 -0
- package/src/blocks/product-attributes-block.tsx +307 -0
- package/src/blocks/product-block-utils.tsx +639 -0
- package/src/blocks/product-breadcrumb-block.tsx +215 -0
- package/src/blocks/product-description-block.tsx +169 -0
- package/src/blocks/product-gallery-block.tsx +1523 -0
- package/src/blocks/product-grid-block.tsx +966 -0
- package/src/blocks/product-price-block.tsx +373 -0
- package/src/blocks/product-recommendations-block.tsx +1433 -0
- package/src/blocks/product-title-block.tsx +129 -0
- package/src/blocks/profile-form-block.tsx +297 -0
- package/src/blocks/recently-viewed-store.ts +201 -0
- package/src/blocks/register-form-block.tsx +362 -0
- package/src/blocks/share-block.tsx +667 -0
- package/src/blocks/size-guide-block.tsx +313 -0
- package/src/blocks/sort-select-block.tsx +636 -0
- package/src/blocks/stock-status-block.tsx +168 -0
- package/src/blocks/store-locator-block.tsx +1184 -0
- package/src/blocks/variant-selector-block.tsx +1110 -0
- package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import {
|
|
7
|
+
DESKTOP_MIN_WIDTH,
|
|
8
|
+
MOBILE_MEDIA_QUERY_COMPACT,
|
|
9
|
+
TABLET_MEDIA_QUERY_COMPACT
|
|
10
|
+
} from '../utils/breakpoints';
|
|
11
|
+
import ThemeBlock, { Block } from '../theme-block';
|
|
12
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
13
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
14
|
+
import {
|
|
15
|
+
MoneyCurrencyDisplay,
|
|
16
|
+
MoneyCurrencyPosition,
|
|
17
|
+
MoneyDecimals,
|
|
18
|
+
MoneyStyleOptions
|
|
19
|
+
} from './commerce-format';
|
|
20
|
+
import {
|
|
21
|
+
listingPlaceholderStyle,
|
|
22
|
+
ListingProduct,
|
|
23
|
+
ListingValueLookups,
|
|
24
|
+
parseListingLookups,
|
|
25
|
+
readToggle,
|
|
26
|
+
useListingContext
|
|
27
|
+
} from './listing-context';
|
|
28
|
+
|
|
29
|
+
const clampInt = (value: unknown, fallback: number, min: number, max: number) => {
|
|
30
|
+
const numeric = Math.round(Number(value));
|
|
31
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
32
|
+
return Math.min(max, Math.max(min, numeric));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const readCount = (value: unknown, fallback: number): number => {
|
|
36
|
+
const numeric = Math.round(Number(value));
|
|
37
|
+
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Remembered per block for the session: a shopper who chose a denser grid
|
|
42
|
+
* expects it to survive paging through the category, and to be forgotten by
|
|
43
|
+
* the time they come back tomorrow.
|
|
44
|
+
*/
|
|
45
|
+
const DENSITY_KEY_PREFIX = 'pz-grid-density:';
|
|
46
|
+
|
|
47
|
+
const readStoredDensity = (key: string): number | null => {
|
|
48
|
+
// sessionStorage throws in Safari private mode and is absent during SSR.
|
|
49
|
+
try {
|
|
50
|
+
const numeric = Math.round(Number(window.sessionStorage.getItem(key)));
|
|
51
|
+
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const writeStoredDensity = (key: string, value: number) => {
|
|
58
|
+
try {
|
|
59
|
+
window.sessionStorage.setItem(key, String(value));
|
|
60
|
+
} catch {
|
|
61
|
+
// A storage failure only costs the shopper the memory of the choice.
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The column counts the designer allows, as an ascending, deduped list. */
|
|
66
|
+
const parseDensityOptions = (raw: string): number[] => {
|
|
67
|
+
const counts = new Set<number>();
|
|
68
|
+
|
|
69
|
+
raw.split(',').forEach((entry) => {
|
|
70
|
+
const numeric = Math.round(Number(entry.trim()));
|
|
71
|
+
if (!Number.isFinite(numeric) || numeric < 1 || numeric > 6) return;
|
|
72
|
+
counts.add(numeric);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return Array.from(counts).sort((a, b) => a - b);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The switcher's mark: as many bars as the layout has columns, which is how
|
|
80
|
+
* both audited designs draw it — the shopper reads the density rather than a
|
|
81
|
+
* number.
|
|
82
|
+
*/
|
|
83
|
+
const DensityGlyph = ({ columns }: { columns: number }) => {
|
|
84
|
+
const gap = 2;
|
|
85
|
+
const cell = (16 - gap * (columns - 1)) / columns;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<svg
|
|
89
|
+
width="16"
|
|
90
|
+
height="16"
|
|
91
|
+
viewBox="0 0 16 16"
|
|
92
|
+
fill="currentColor"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
{...partAttrs('density-icon')}
|
|
95
|
+
>
|
|
96
|
+
{Array.from({ length: columns }, (_, index) => (
|
|
97
|
+
<rect
|
|
98
|
+
key={index}
|
|
99
|
+
x={index * (cell + gap)}
|
|
100
|
+
y={0}
|
|
101
|
+
width={cell}
|
|
102
|
+
height={16}
|
|
103
|
+
rx={1}
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
106
|
+
</svg>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Two things a grid child can be besides a product card.
|
|
112
|
+
*
|
|
113
|
+
* Both are read off the same `role` property the row layout already uses, and
|
|
114
|
+
* a child carrying neither goes on rendering exactly what it renders today —
|
|
115
|
+
* nothing, because the grid only ever looked for the row layout.
|
|
116
|
+
*
|
|
117
|
+
* `grid-insertion` is the merchandiser tile both audited brands hand-code: a
|
|
118
|
+
* campaign panel that takes a cell of the grid instead of sitting above or
|
|
119
|
+
* below it. `listing-empty` is what the page shows in place of the grid when
|
|
120
|
+
* the filters return nothing — a composition, so it can carry the heading, the
|
|
121
|
+
* explanation, the applied filters and the suggestions the designs ask for.
|
|
122
|
+
*/
|
|
123
|
+
const GRID_INSERTION_ROLE = 'grid-insertion';
|
|
124
|
+
const EMPTY_STATE_ROLE = 'listing-empty';
|
|
125
|
+
|
|
126
|
+
const readChildRole = (child: Block, breakpoint: string): string =>
|
|
127
|
+
String(getResponsiveValue(child.properties?.role, breakpoint, '') ?? '');
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The same rule the group block applies to its children: a hidden child stays
|
|
131
|
+
* mounted on the canvas so it can be brought back, and is gone on the
|
|
132
|
+
* storefront.
|
|
133
|
+
*/
|
|
134
|
+
const isChildRendered = (child: Block, isDesigner: boolean): boolean =>
|
|
135
|
+
isDesigner || !child.hidden;
|
|
136
|
+
|
|
137
|
+
interface GridInsertion {
|
|
138
|
+
block: Block;
|
|
139
|
+
/** 1-based cell the tile was authored into, before it is clamped. */
|
|
140
|
+
position: number;
|
|
141
|
+
columnSpan: number;
|
|
142
|
+
rowSpan: number;
|
|
143
|
+
/** A campaign tile is normally page one only; `true` repeats it on every page. */
|
|
144
|
+
everyPage: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const collectInsertions = (
|
|
148
|
+
block: Block,
|
|
149
|
+
breakpoint: string,
|
|
150
|
+
isDesigner: boolean
|
|
151
|
+
): GridInsertion[] =>
|
|
152
|
+
(block.blocks || [])
|
|
153
|
+
.filter(
|
|
154
|
+
(child) =>
|
|
155
|
+
readChildRole(child, breakpoint) === GRID_INSERTION_ROLE &&
|
|
156
|
+
isChildRendered(child, isDesigner)
|
|
157
|
+
)
|
|
158
|
+
.map((child) => ({
|
|
159
|
+
block: child,
|
|
160
|
+
position: clampInt(
|
|
161
|
+
getResponsiveValue(child.properties?.insertPosition, breakpoint, 1),
|
|
162
|
+
1,
|
|
163
|
+
1,
|
|
164
|
+
96
|
|
165
|
+
),
|
|
166
|
+
columnSpan: clampInt(
|
|
167
|
+
getResponsiveValue(child.properties?.insertColumnSpan, breakpoint, 1),
|
|
168
|
+
1,
|
|
169
|
+
1,
|
|
170
|
+
6
|
|
171
|
+
),
|
|
172
|
+
rowSpan: clampInt(
|
|
173
|
+
getResponsiveValue(child.properties?.insertRowSpan, breakpoint, 1),
|
|
174
|
+
1,
|
|
175
|
+
1,
|
|
176
|
+
4
|
|
177
|
+
),
|
|
178
|
+
everyPage:
|
|
179
|
+
String(
|
|
180
|
+
getResponsiveValue(child.properties?.insertPages, breakpoint, 'first')
|
|
181
|
+
) === 'all'
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
interface PlacedInsertion extends GridInsertion {
|
|
185
|
+
/** 1-based cell the tile ends up in once the page is known. */
|
|
186
|
+
cell: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Where each tile actually lands.
|
|
191
|
+
*
|
|
192
|
+
* A filter combination that returns three products would otherwise leave a
|
|
193
|
+
* tile authored for cell 5 pinned to a cell nothing can reach, punching a hole
|
|
194
|
+
* through the row. Sorting first and then pulling each tile back to at most
|
|
195
|
+
* one cell past the products before it keeps the grid gapless, and keeps two
|
|
196
|
+
* tiles authored onto the same cell from landing on top of each other.
|
|
197
|
+
*/
|
|
198
|
+
export const placeInsertions = (
|
|
199
|
+
insertions: GridInsertion[],
|
|
200
|
+
productCount: number
|
|
201
|
+
): PlacedInsertion[] => {
|
|
202
|
+
let previousCell = 0;
|
|
203
|
+
|
|
204
|
+
return [...insertions]
|
|
205
|
+
.sort((a, b) => a.position - b.position)
|
|
206
|
+
.map((insertion, index) => {
|
|
207
|
+
const lastReachableCell = productCount + index + 1;
|
|
208
|
+
const cell = Math.max(
|
|
209
|
+
previousCell + 1,
|
|
210
|
+
Math.min(insertion.position, lastReachableCell)
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
previousCell = cell;
|
|
214
|
+
return { ...insertion, cell };
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A tile's cell has to be recomputed for every column count the block emits:
|
|
220
|
+
* cell 5 is the start of row 2 in a four-column grid and of row 3 in a
|
|
221
|
+
* two-column one, and a two-column tile in a one-column grid would hang off
|
|
222
|
+
* the edge. Written into the block's stylesheet rather than inline for the
|
|
223
|
+
* same reason the column template is — an inline style would outrank the
|
|
224
|
+
* mobile override and the shopper's density choice.
|
|
225
|
+
*
|
|
226
|
+
* Every rule names the page it was written for. "Cell 5" means the fifth cell
|
|
227
|
+
* of the page being read, so the rule is only true of that page's tiles — and
|
|
228
|
+
* Load More can put several pages in one grid, where the tile index restarts
|
|
229
|
+
* at 0 on each. Without the page in the selector, page one's rule also claims
|
|
230
|
+
* page two's tile: both are definitely placed in the same cell, stacked on
|
|
231
|
+
* each other and over a card the shopper was already looking at. An appended
|
|
232
|
+
* page's tiles carry a page nothing on the live page has a rule for, so they
|
|
233
|
+
* are auto-placed into their own run instead of moving anything.
|
|
234
|
+
*/
|
|
235
|
+
export const buildInsertionRules = (
|
|
236
|
+
gridId: string,
|
|
237
|
+
placed: PlacedInsertion[],
|
|
238
|
+
columns: number,
|
|
239
|
+
page: number
|
|
240
|
+
): string =>
|
|
241
|
+
placed
|
|
242
|
+
.map((insertion, index) => {
|
|
243
|
+
const span = Math.min(insertion.columnSpan, columns);
|
|
244
|
+
const column = ((insertion.cell - 1) % columns) + 1;
|
|
245
|
+
const row = Math.floor((insertion.cell - 1) / columns) + 1;
|
|
246
|
+
// Pulled back inside the last track when the span would overhang it.
|
|
247
|
+
const start = Math.min(column, columns - span + 1);
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
`[data-product-grid-id="${gridId}"] ` +
|
|
251
|
+
`[data-product-grid-page="${page}"][data-product-grid-insert="${index}"]` +
|
|
252
|
+
`{grid-column:${start}/span ${span};grid-row:${row}/span ${insertion.rowSpan};}`
|
|
253
|
+
);
|
|
254
|
+
})
|
|
255
|
+
.join('');
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Tiles are pinned by the stylesheet, so both card paths put them in the same
|
|
259
|
+
* cell whatever their DOM position. Where the grid draws its own cards it can
|
|
260
|
+
* also put them in DOM order, which is the order a keyboard and a screen
|
|
261
|
+
* reader follow; a designer-authored row layout renders as one unit, so there
|
|
262
|
+
* the tiles trail it and only the stylesheet places them.
|
|
263
|
+
*/
|
|
264
|
+
export const interleaveCells = (
|
|
265
|
+
cards: React.ReactNode[],
|
|
266
|
+
tiles: React.ReactNode[],
|
|
267
|
+
placed: PlacedInsertion[]
|
|
268
|
+
): React.ReactNode[] => {
|
|
269
|
+
const cells: React.ReactNode[] = [];
|
|
270
|
+
let tileIndex = 0;
|
|
271
|
+
let cellNumber = 0;
|
|
272
|
+
|
|
273
|
+
const takeDueTiles = () => {
|
|
274
|
+
while (
|
|
275
|
+
tileIndex < placed.length &&
|
|
276
|
+
placed[tileIndex].cell === cellNumber + 1
|
|
277
|
+
) {
|
|
278
|
+
cells.push(tiles[tileIndex]);
|
|
279
|
+
tileIndex += 1;
|
|
280
|
+
cellNumber += 1;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
cards.forEach((card) => {
|
|
285
|
+
takeDueTiles();
|
|
286
|
+
cells.push(card);
|
|
287
|
+
cellNumber += 1;
|
|
288
|
+
});
|
|
289
|
+
takeDueTiles();
|
|
290
|
+
|
|
291
|
+
// Clamping should leave nothing behind; appending the remainder means a
|
|
292
|
+
// miscount would misplace a tile rather than delete the campaign.
|
|
293
|
+
while (tileIndex < placed.length) {
|
|
294
|
+
cells.push(tiles[tileIndex]);
|
|
295
|
+
tileIndex += 1;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return cells;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const IMAGE_RATIOS = ['auto', '1 / 1', '3 / 4', '2 / 3', '4 / 5', '16 / 9'];
|
|
302
|
+
const DECIMALS = ['always', 'auto', 'never'];
|
|
303
|
+
const CURRENCY_DISPLAYS = ['', 'symbol', 'code', 'custom', 'none'];
|
|
304
|
+
const CURRENCY_POSITIONS = ['', 'before', 'after'];
|
|
305
|
+
|
|
306
|
+
/** Reads one of a fixed set, so a hand-edited value cannot reach Intl. */
|
|
307
|
+
const readOption = <T extends string>(
|
|
308
|
+
value: unknown,
|
|
309
|
+
breakpoint: string,
|
|
310
|
+
allowed: string[],
|
|
311
|
+
fallback: T
|
|
312
|
+
): T => {
|
|
313
|
+
const raw = String(getResponsiveValue(value, breakpoint, fallback));
|
|
314
|
+
return (allowed.includes(raw) ? raw : fallback) as T;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Functional listing block: the product grid of a category/search/PLP page.
|
|
319
|
+
* Product CONTENT stays commerce-driven (it comes from the listing page
|
|
320
|
+
* context built server side); the editor owns column counts, which card
|
|
321
|
+
* fields appear, the image frame and styling.
|
|
322
|
+
*
|
|
323
|
+
* An optional shopper-facing density switcher (off by default) lets the
|
|
324
|
+
* column count be changed on the page and remembers it for the session; the
|
|
325
|
+
* server always renders the count the designer authored, so the markup is
|
|
326
|
+
* complete before any script runs.
|
|
327
|
+
*
|
|
328
|
+
* Two kinds of authored child extend what the grid can express: a child marked
|
|
329
|
+
* `grid-insertion` is a merchandiser tile that takes a cell of the grid, and a
|
|
330
|
+
* child marked `listing-empty` is the composition shown in place of the grid
|
|
331
|
+
* when the listing returns nothing. Neither exists until a designer marks one,
|
|
332
|
+
* so a published grid renders exactly what it rendered before.
|
|
333
|
+
*
|
|
334
|
+
* Parts: list, card, image, name, price, sale-price, list-price, badge,
|
|
335
|
+
* density, density-label, density-option, density-icon, insert, empty.
|
|
336
|
+
* Properties: columnsDesktop, columnsMobile, limit, showName, nameLines,
|
|
337
|
+
* imageRatio, imageFit, showPrice, showRetailPrice, showDiscountBadge,
|
|
338
|
+
* discountBadgeTemplate, badgePosition, decimals, currencyDisplay,
|
|
339
|
+
* currencyText, currencyPosition, showDensitySwitcher, densityOptions,
|
|
340
|
+
* densityStyle, densityPosition, densityLabel, densityAriaLabel,
|
|
341
|
+
* previewEmptyState, badgeAttribute, badgeMap, swatchMap.
|
|
342
|
+
* Child properties (on a `grid-insertion` child): insertPosition,
|
|
343
|
+
* insertColumnSpan, insertRowSpan, insertPages.
|
|
344
|
+
*/
|
|
345
|
+
const ProductGridBlock = ({
|
|
346
|
+
block,
|
|
347
|
+
placeholderId,
|
|
348
|
+
sectionId,
|
|
349
|
+
selectedBlockId = null,
|
|
350
|
+
currentBreakpoint = 'desktop',
|
|
351
|
+
isDesigner = false
|
|
352
|
+
}: BlockRendererProps) => {
|
|
353
|
+
// The card's money format. Read before the listing is resolved because the
|
|
354
|
+
// listing formats every price on the row with it — the built-in card and a
|
|
355
|
+
// designer-authored one therefore read the same. Memoised on the values, or
|
|
356
|
+
// the listing would be rebuilt on every render.
|
|
357
|
+
const decimals = readOption<MoneyDecimals>(
|
|
358
|
+
block.properties?.decimals,
|
|
359
|
+
currentBreakpoint,
|
|
360
|
+
DECIMALS,
|
|
361
|
+
'always'
|
|
362
|
+
);
|
|
363
|
+
const currencyDisplay = readOption<MoneyCurrencyDisplay>(
|
|
364
|
+
block.properties?.currencyDisplay,
|
|
365
|
+
currentBreakpoint,
|
|
366
|
+
CURRENCY_DISPLAYS,
|
|
367
|
+
''
|
|
368
|
+
);
|
|
369
|
+
const currencyPosition = readOption<MoneyCurrencyPosition>(
|
|
370
|
+
block.properties?.currencyPosition,
|
|
371
|
+
currentBreakpoint,
|
|
372
|
+
CURRENCY_POSITIONS,
|
|
373
|
+
''
|
|
374
|
+
);
|
|
375
|
+
const currencyText = String(
|
|
376
|
+
getResponsiveValue(block.properties?.currencyText, currentBreakpoint, '')
|
|
377
|
+
);
|
|
378
|
+
const money = React.useMemo<MoneyStyleOptions>(
|
|
379
|
+
() => ({ decimals, currencyDisplay, currencyText, currencyPosition }),
|
|
380
|
+
[decimals, currencyDisplay, currencyText, currencyPosition]
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
// The author's value -> presentation tables. Commerce sends a badge as a bare
|
|
384
|
+
// key ("new_season") and a colour as a bare value ("kirmizi") and carries no
|
|
385
|
+
// label, image or hex for either, so `item.badge.*` and `item.colors[].color`
|
|
386
|
+
// can only carry anything once a designer maps them here. Nothing authored
|
|
387
|
+
// means no table, which is the row every published grid already has.
|
|
388
|
+
const badgeAttribute = String(
|
|
389
|
+
getResponsiveValue(block.properties?.badgeAttribute, currentBreakpoint, '')
|
|
390
|
+
);
|
|
391
|
+
const badgeMap = String(
|
|
392
|
+
getResponsiveValue(block.properties?.badgeMap, currentBreakpoint, '')
|
|
393
|
+
);
|
|
394
|
+
const swatchMap = String(
|
|
395
|
+
getResponsiveValue(block.properties?.swatchMap, currentBreakpoint, '')
|
|
396
|
+
);
|
|
397
|
+
const lookups = React.useMemo<ListingValueLookups | undefined>(
|
|
398
|
+
() => parseListingLookups(badgeAttribute, badgeMap, swatchMap),
|
|
399
|
+
[badgeAttribute, badgeMap, swatchMap]
|
|
400
|
+
);
|
|
401
|
+
const listing = useListingContext(money, lookups);
|
|
402
|
+
|
|
403
|
+
const columnsDesktop = clampInt(
|
|
404
|
+
getResponsiveValue(block.properties?.columnsDesktop, currentBreakpoint, 4),
|
|
405
|
+
4,
|
|
406
|
+
2,
|
|
407
|
+
6
|
|
408
|
+
);
|
|
409
|
+
const columnsMobile = clampInt(
|
|
410
|
+
getResponsiveValue(block.properties?.columnsMobile, currentBreakpoint, 2),
|
|
411
|
+
2,
|
|
412
|
+
1,
|
|
413
|
+
3
|
|
414
|
+
);
|
|
415
|
+
// The grid a tablet actually wants — three across, where a phone wants two
|
|
416
|
+
// and a desktop wants four or six. Left unset it is the mobile count, which
|
|
417
|
+
// is what the band has shown since there was only one media query, so a grid
|
|
418
|
+
// that never authored it emits exactly the CSS it emitted before.
|
|
419
|
+
const columnsTabletRaw = block.properties?.columnsTablet;
|
|
420
|
+
const columnsTabletResolved =
|
|
421
|
+
columnsTabletRaw === undefined ||
|
|
422
|
+
columnsTabletRaw === null ||
|
|
423
|
+
columnsTabletRaw === ''
|
|
424
|
+
? null
|
|
425
|
+
: Number(getResponsiveValue(columnsTabletRaw, currentBreakpoint, 0));
|
|
426
|
+
// 0 is the descriptor's "keep the mobile count" setting, and so is a value
|
|
427
|
+
// that is missing or unparseable — all three mean no tablet rule.
|
|
428
|
+
const columnsTablet =
|
|
429
|
+
columnsTabletResolved === null ||
|
|
430
|
+
!Number.isFinite(columnsTabletResolved) ||
|
|
431
|
+
columnsTabletResolved < 1
|
|
432
|
+
? null
|
|
433
|
+
: clampInt(
|
|
434
|
+
getResponsiveValue(columnsTabletRaw, currentBreakpoint, columnsMobile),
|
|
435
|
+
columnsMobile,
|
|
436
|
+
1,
|
|
437
|
+
6
|
|
438
|
+
);
|
|
439
|
+
// 0 renders every product the listing returned, which is today's behaviour.
|
|
440
|
+
const limit = readCount(
|
|
441
|
+
getResponsiveValue(block.properties?.limit, currentBreakpoint, 0),
|
|
442
|
+
0
|
|
443
|
+
);
|
|
444
|
+
const showName = readToggle(
|
|
445
|
+
block.properties?.showName,
|
|
446
|
+
currentBreakpoint,
|
|
447
|
+
true
|
|
448
|
+
);
|
|
449
|
+
const nameLines = readCount(
|
|
450
|
+
getResponsiveValue(block.properties?.nameLines, currentBreakpoint, 0),
|
|
451
|
+
0
|
|
452
|
+
);
|
|
453
|
+
const imageRatioRaw = String(
|
|
454
|
+
getResponsiveValue(block.properties?.imageRatio, currentBreakpoint, 'auto')
|
|
455
|
+
);
|
|
456
|
+
const imageRatio = IMAGE_RATIOS.includes(imageRatioRaw)
|
|
457
|
+
? imageRatioRaw
|
|
458
|
+
: 'auto';
|
|
459
|
+
const imageFit = String(
|
|
460
|
+
getResponsiveValue(block.properties?.imageFit, currentBreakpoint, 'cover')
|
|
461
|
+
);
|
|
462
|
+
const showPrice = readToggle(
|
|
463
|
+
block.properties?.showPrice,
|
|
464
|
+
currentBreakpoint,
|
|
465
|
+
true
|
|
466
|
+
);
|
|
467
|
+
const showRetailPrice = readToggle(
|
|
468
|
+
block.properties?.showRetailPrice,
|
|
469
|
+
currentBreakpoint,
|
|
470
|
+
true
|
|
471
|
+
);
|
|
472
|
+
const showDiscountBadge = readToggle(
|
|
473
|
+
block.properties?.showDiscountBadge,
|
|
474
|
+
currentBreakpoint,
|
|
475
|
+
false
|
|
476
|
+
);
|
|
477
|
+
const discountBadgeTemplate = String(
|
|
478
|
+
getResponsiveValue(
|
|
479
|
+
block.properties?.discountBadgeTemplate,
|
|
480
|
+
currentBreakpoint,
|
|
481
|
+
'-{percent}%'
|
|
482
|
+
)
|
|
483
|
+
);
|
|
484
|
+
const badgePosition = String(
|
|
485
|
+
getResponsiveValue(block.properties?.badgePosition, currentBreakpoint, 'inline')
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
const showDensitySwitcher = readToggle(
|
|
489
|
+
block.properties?.showDensitySwitcher,
|
|
490
|
+
currentBreakpoint,
|
|
491
|
+
false
|
|
492
|
+
);
|
|
493
|
+
const densityOptionsRaw = String(
|
|
494
|
+
getResponsiveValue(
|
|
495
|
+
block.properties?.densityOptions,
|
|
496
|
+
currentBreakpoint,
|
|
497
|
+
'1,2,4'
|
|
498
|
+
)
|
|
499
|
+
);
|
|
500
|
+
const densityStyle = String(
|
|
501
|
+
getResponsiveValue(block.properties?.densityStyle, currentBreakpoint, 'icon')
|
|
502
|
+
);
|
|
503
|
+
const densityPosition = String(
|
|
504
|
+
getResponsiveValue(
|
|
505
|
+
block.properties?.densityPosition,
|
|
506
|
+
currentBreakpoint,
|
|
507
|
+
'before'
|
|
508
|
+
)
|
|
509
|
+
);
|
|
510
|
+
const densityLabel = String(
|
|
511
|
+
getResponsiveValue(block.properties?.densityLabel, currentBreakpoint, '')
|
|
512
|
+
);
|
|
513
|
+
const densityAriaLabel = String(
|
|
514
|
+
getResponsiveValue(
|
|
515
|
+
block.properties?.densityAriaLabel,
|
|
516
|
+
currentBreakpoint,
|
|
517
|
+
'Products per row'
|
|
518
|
+
)
|
|
519
|
+
);
|
|
520
|
+
// Editor-only: a category with stock never renders its no-results layout, so
|
|
521
|
+
// without this the designer has nothing to select and arrange. Guarded by
|
|
522
|
+
// isDesigner at the use site, so it can never empty a storefront listing.
|
|
523
|
+
const previewEmptyState = readToggle(
|
|
524
|
+
block.properties?.previewEmptyState,
|
|
525
|
+
currentBreakpoint,
|
|
526
|
+
false
|
|
527
|
+
);
|
|
528
|
+
const densityOptions = React.useMemo(
|
|
529
|
+
() => parseDensityOptions(densityOptionsRaw),
|
|
530
|
+
[densityOptionsRaw]
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
// null until the shopper picks one, which is what keeps the server markup and
|
|
534
|
+
// the first client render identical.
|
|
535
|
+
const [chosenColumns, setChosenColumns] = React.useState<number | null>(null);
|
|
536
|
+
// false on the server and on the first client render, for the same reason.
|
|
537
|
+
const [isNarrowViewport, setIsNarrowViewport] = React.useState(false);
|
|
538
|
+
const densityKey = `${DENSITY_KEY_PREFIX}${block.id || 'grid'}`;
|
|
539
|
+
const hasSwitcher = showDensitySwitcher && densityOptions.length > 1;
|
|
540
|
+
|
|
541
|
+
React.useEffect(() => {
|
|
542
|
+
// Read after mount only: the server has no session storage, so deciding
|
|
543
|
+
// during render would hydrate as a mismatch. The designer always sees the
|
|
544
|
+
// authored count — a remembered choice there would look like a bug.
|
|
545
|
+
if (!hasSwitcher || isDesigner) return;
|
|
546
|
+
|
|
547
|
+
const stored = readStoredDensity(densityKey);
|
|
548
|
+
if (stored !== null && densityOptions.includes(stored)) {
|
|
549
|
+
setChosenColumns(stored);
|
|
550
|
+
}
|
|
551
|
+
}, [hasSwitcher, isDesigner, densityKey, densityOptions]);
|
|
552
|
+
|
|
553
|
+
React.useEffect(() => {
|
|
554
|
+
// A real phone gets its column count from the media query the block emits,
|
|
555
|
+
// never from currentBreakpoint — which is 'desktop' on the storefront no
|
|
556
|
+
// matter how narrow the viewport is. The switcher has to read the same
|
|
557
|
+
// cascade to mark the option the shopper is actually looking at, so it
|
|
558
|
+
// watches the identical cutoff, taken from the same constant the emitted
|
|
559
|
+
// media query is built from rather than retyped.
|
|
560
|
+
if (!hasSwitcher || typeof window.matchMedia !== 'function') return;
|
|
561
|
+
|
|
562
|
+
const query = window.matchMedia(`(max-width: ${DESKTOP_MIN_WIDTH - 1}px)`);
|
|
563
|
+
const sync = () => setIsNarrowViewport(query.matches);
|
|
564
|
+
|
|
565
|
+
sync();
|
|
566
|
+
|
|
567
|
+
// Safari below 14 only carries the deprecated listener pair.
|
|
568
|
+
if (typeof query.addEventListener === 'function') {
|
|
569
|
+
query.addEventListener('change', sync);
|
|
570
|
+
return () => query.removeEventListener('change', sync);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
query.addListener(sync);
|
|
574
|
+
return () => query.removeListener(sync);
|
|
575
|
+
}, [hasSwitcher]);
|
|
576
|
+
|
|
577
|
+
const allProducts = listing?.products || [];
|
|
578
|
+
const products = limit > 0 ? allProducts.slice(0, limit) : allProducts;
|
|
579
|
+
|
|
580
|
+
// A designer who marked a child as the card layout gets that layout
|
|
581
|
+
// repeated per product; otherwise the built-in card renders unchanged.
|
|
582
|
+
const customCards = hasItemTemplate(block, currentBreakpoint) ? (
|
|
583
|
+
<ItemTemplateList
|
|
584
|
+
block={block}
|
|
585
|
+
items={products as unknown as Record<string, unknown>[]}
|
|
586
|
+
placeholderId={placeholderId}
|
|
587
|
+
sectionId={sectionId}
|
|
588
|
+
isDesigner={isDesigner}
|
|
589
|
+
selectedBlockId={selectedBlockId}
|
|
590
|
+
currentBreakpoint={currentBreakpoint}
|
|
591
|
+
designerPlaceholderCount={4}
|
|
592
|
+
/>
|
|
593
|
+
) : null;
|
|
594
|
+
|
|
595
|
+
const childProps = {
|
|
596
|
+
placeholderId,
|
|
597
|
+
sectionId,
|
|
598
|
+
isDesigner,
|
|
599
|
+
selectedBlockId,
|
|
600
|
+
currentBreakpoint
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// "The listing answered with nothing" is not the same as "there is no
|
|
604
|
+
// listing here": a grid dropped on a page that never had one keeps rendering
|
|
605
|
+
// nothing rather than announcing an empty search.
|
|
606
|
+
const isEmptyListing = listing !== null && allProducts.length === 0;
|
|
607
|
+
const emptyStateChild =
|
|
608
|
+
(block.blocks || []).find(
|
|
609
|
+
(child) =>
|
|
610
|
+
readChildRole(child, currentBreakpoint) === EMPTY_STATE_ROLE &&
|
|
611
|
+
isChildRendered(child, isDesigner)
|
|
612
|
+
) ?? null;
|
|
613
|
+
|
|
614
|
+
// The authored no-results page replaces the grid entirely — the density
|
|
615
|
+
// switcher and the cards have nothing to act on once the listing is empty.
|
|
616
|
+
if (
|
|
617
|
+
emptyStateChild &&
|
|
618
|
+
(isEmptyListing || (isDesigner && previewEmptyState))
|
|
619
|
+
) {
|
|
620
|
+
return (
|
|
621
|
+
<div data-product-grid-empty="" {...partAttrs('empty')}>
|
|
622
|
+
<ThemeBlock
|
|
623
|
+
block={emptyStateChild}
|
|
624
|
+
isSelected={selectedBlockId === emptyStateChild.id}
|
|
625
|
+
{...childProps}
|
|
626
|
+
/>
|
|
627
|
+
</div>
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const currentPage = listing?.pagination.currentPage ?? 1;
|
|
632
|
+
// A campaign tile is authored for the first page unless the designer says
|
|
633
|
+
// otherwise; repeating it every page is the exception, not the default.
|
|
634
|
+
const placedInsertions = placeInsertions(
|
|
635
|
+
collectInsertions(block, currentBreakpoint, isDesigner).filter(
|
|
636
|
+
(insertion) => insertion.everyPage || currentPage === 1
|
|
637
|
+
),
|
|
638
|
+
products.length
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
// A grid with no products still draws its tiles when the listing itself came
|
|
642
|
+
// back empty and nothing was authored to replace it — the campaign is the
|
|
643
|
+
// only thing left to show. On the canvas they always draw, or a designer
|
|
644
|
+
// could not place one against a page with no live data.
|
|
645
|
+
const drawsInsertionsOnly =
|
|
646
|
+
placedInsertions.length > 0 && (isDesigner || isEmptyListing);
|
|
647
|
+
|
|
648
|
+
// With a custom card the designer still needs something to arrange, so the
|
|
649
|
+
// template draws its own placeholder rows rather than the "no data" notice.
|
|
650
|
+
if (products.length === 0 && !customCards && !drawsInsertionsOnly) {
|
|
651
|
+
return isDesigner ? (
|
|
652
|
+
<div data-product-grid="" style={listingPlaceholderStyle}>
|
|
653
|
+
Product grid (no listing data)
|
|
654
|
+
</div>
|
|
655
|
+
) : null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Column counts are block PROPERTIES, and properties are resolved with the
|
|
659
|
+
// designer breakpoint only — on the live storefront currentBreakpoint is
|
|
660
|
+
// always 'desktop'. A scoped rule with the same 1023px cutoff the theme CSS
|
|
661
|
+
// generator uses keeps real mobile viewports in sync with the editor.
|
|
662
|
+
const gridId = String(block.id || 'grid').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
663
|
+
const baseColumns =
|
|
664
|
+
currentBreakpoint === 'tablet'
|
|
665
|
+
? columnsTablet ?? columnsMobile
|
|
666
|
+
: currentBreakpoint === 'mobile'
|
|
667
|
+
? columnsMobile
|
|
668
|
+
: columnsDesktop;
|
|
669
|
+
|
|
670
|
+
const isOverlayBadge = badgePosition === 'overlay';
|
|
671
|
+
const nameStyle: React.CSSProperties =
|
|
672
|
+
nameLines > 0
|
|
673
|
+
? {
|
|
674
|
+
fontSize: 'inherit',
|
|
675
|
+
display: '-webkit-box',
|
|
676
|
+
WebkitBoxOrient: 'vertical',
|
|
677
|
+
WebkitLineClamp: nameLines,
|
|
678
|
+
overflow: 'hidden'
|
|
679
|
+
}
|
|
680
|
+
: { fontSize: 'inherit' };
|
|
681
|
+
|
|
682
|
+
// The three rules below in the order the browser applies them: the authored
|
|
683
|
+
// base, the mobile override, then an explicit choice. Deriving the mark from
|
|
684
|
+
// baseColumns alone highlighted the desktop count on every phone.
|
|
685
|
+
const activeColumns =
|
|
686
|
+
chosenColumns ?? (isNarrowViewport ? columnsMobile : baseColumns);
|
|
687
|
+
|
|
688
|
+
const densitySwitcher = hasSwitcher ? (
|
|
689
|
+
<div
|
|
690
|
+
data-product-grid-density=""
|
|
691
|
+
{...partAttrs('density')}
|
|
692
|
+
role="group"
|
|
693
|
+
aria-label={densityAriaLabel}
|
|
694
|
+
style={{
|
|
695
|
+
display: 'flex',
|
|
696
|
+
alignItems: 'center',
|
|
697
|
+
gap: 'var(--product-grid-density-gap, 8px)',
|
|
698
|
+
marginBottom: densityPosition === 'after' ? undefined : '12px',
|
|
699
|
+
marginTop: densityPosition === 'after' ? '12px' : undefined
|
|
700
|
+
}}
|
|
701
|
+
>
|
|
702
|
+
{densityLabel && (
|
|
703
|
+
<span
|
|
704
|
+
data-product-grid-density-label=""
|
|
705
|
+
{...partAttrs('density-label')}
|
|
706
|
+
>
|
|
707
|
+
{densityLabel}
|
|
708
|
+
</span>
|
|
709
|
+
)}
|
|
710
|
+
{densityOptions.map((count) => {
|
|
711
|
+
const isActive = count === activeColumns;
|
|
712
|
+
|
|
713
|
+
return (
|
|
714
|
+
<button
|
|
715
|
+
key={count}
|
|
716
|
+
type="button"
|
|
717
|
+
data-product-grid-density-option={String(count)}
|
|
718
|
+
{...partAttrs('density-option', { selected: isActive })}
|
|
719
|
+
aria-pressed={isActive}
|
|
720
|
+
aria-label={String(count)}
|
|
721
|
+
onClick={() => {
|
|
722
|
+
setChosenColumns(count);
|
|
723
|
+
// Writing the author's canvas experiments into their own session
|
|
724
|
+
// would follow them onto the storefront.
|
|
725
|
+
if (!isDesigner) writeStoredDensity(densityKey, count);
|
|
726
|
+
}}
|
|
727
|
+
style={{
|
|
728
|
+
display: 'inline-flex',
|
|
729
|
+
alignItems: 'center',
|
|
730
|
+
background: 'none',
|
|
731
|
+
border: 'none',
|
|
732
|
+
padding: '4px',
|
|
733
|
+
cursor: 'pointer',
|
|
734
|
+
color: 'inherit',
|
|
735
|
+
font: 'inherit',
|
|
736
|
+
opacity: isActive ? 1 : 0.45
|
|
737
|
+
}}
|
|
738
|
+
>
|
|
739
|
+
{densityStyle === 'number' ? count : <DensityGlyph columns={count} />}
|
|
740
|
+
</button>
|
|
741
|
+
);
|
|
742
|
+
})}
|
|
743
|
+
</div>
|
|
744
|
+
) : null;
|
|
745
|
+
|
|
746
|
+
const insertionTiles = placedInsertions.map((insertion, index) => (
|
|
747
|
+
<div
|
|
748
|
+
key={`insert-${insertion.block.id || index}`}
|
|
749
|
+
// Indexed rather than keyed by block id: the stylesheet above addresses
|
|
750
|
+
// the same index, and a block id is not guaranteed to be selector-safe.
|
|
751
|
+
data-product-grid-insert={String(index)}
|
|
752
|
+
// The index restarts at 0 on every page, so the page is what makes the
|
|
753
|
+
// pair unique once Load More has appended one grid's pages into another.
|
|
754
|
+
data-product-grid-page={String(currentPage)}
|
|
755
|
+
{...partAttrs('insert')}
|
|
756
|
+
// A grid container with a single child stretches it over the whole
|
|
757
|
+
// spanned area, which is what a two-by-two campaign panel needs; a plain
|
|
758
|
+
// block wrapper would leave the panel its content height.
|
|
759
|
+
style={{ display: 'grid', minWidth: 0 }}
|
|
760
|
+
>
|
|
761
|
+
<ThemeBlock
|
|
762
|
+
block={insertion.block}
|
|
763
|
+
isSelected={selectedBlockId === insertion.block.id}
|
|
764
|
+
{...childProps}
|
|
765
|
+
/>
|
|
766
|
+
</div>
|
|
767
|
+
));
|
|
768
|
+
|
|
769
|
+
const renderProductCard = (product: ListingProduct) => {
|
|
770
|
+
// Taken from the row, which derived it from the raw amounts: reading
|
|
771
|
+
// it back out of the formatted string breaks the moment a brand
|
|
772
|
+
// formats money its own way ("1.599 TL" parses as 1.599).
|
|
773
|
+
const percent = product.discountPercent ?? 0;
|
|
774
|
+
const badgeLabel = discountBadgeTemplate.includes('{percent}')
|
|
775
|
+
? discountBadgeTemplate.replace('{percent}', String(percent))
|
|
776
|
+
: discountBadgeTemplate;
|
|
777
|
+
|
|
778
|
+
return (
|
|
779
|
+
<NextLink
|
|
780
|
+
key={product.key}
|
|
781
|
+
href={product.url}
|
|
782
|
+
data-product-card=""
|
|
783
|
+
{...partAttrs('card')}
|
|
784
|
+
// Navigating inside the designer iframe would drop the author out of
|
|
785
|
+
// the page they are editing, so cards are inert there.
|
|
786
|
+
onClick={(event) => {
|
|
787
|
+
if (isDesigner) event.preventDefault();
|
|
788
|
+
}}
|
|
789
|
+
style={{
|
|
790
|
+
display: 'flex',
|
|
791
|
+
flexDirection: 'column',
|
|
792
|
+
gap: '8px',
|
|
793
|
+
color: 'inherit',
|
|
794
|
+
textDecoration: 'none',
|
|
795
|
+
// Only when a badge is asked to float over the image: a positioned
|
|
796
|
+
// ancestor is what makes that possible, and creating one by
|
|
797
|
+
// default would change every published card's containing block.
|
|
798
|
+
...(isOverlayBadge && showDiscountBadge
|
|
799
|
+
? { position: 'relative' }
|
|
800
|
+
: null)
|
|
801
|
+
}}
|
|
802
|
+
>
|
|
803
|
+
{product.image ? (
|
|
804
|
+
<img
|
|
805
|
+
src={product.image}
|
|
806
|
+
alt={product.name}
|
|
807
|
+
loading="lazy"
|
|
808
|
+
data-product-card-image=""
|
|
809
|
+
{...partAttrs('image')}
|
|
810
|
+
style={{
|
|
811
|
+
width: '100%',
|
|
812
|
+
height: 'auto',
|
|
813
|
+
display: 'block',
|
|
814
|
+
objectFit: imageFit as React.CSSProperties['objectFit'],
|
|
815
|
+
...(imageRatio !== 'auto' ? { aspectRatio: imageRatio } : null)
|
|
816
|
+
}}
|
|
817
|
+
/>
|
|
818
|
+
) : (
|
|
819
|
+
<span
|
|
820
|
+
data-product-card-image=""
|
|
821
|
+
{...partAttrs('image', { empty: true })}
|
|
822
|
+
style={{
|
|
823
|
+
display: 'block',
|
|
824
|
+
width: '100%',
|
|
825
|
+
aspectRatio: imageRatio !== 'auto' ? imageRatio : '3 / 4',
|
|
826
|
+
background: '#f1f5f9'
|
|
827
|
+
}}
|
|
828
|
+
/>
|
|
829
|
+
)}
|
|
830
|
+
|
|
831
|
+
{showDiscountBadge && product.hasDiscount && (
|
|
832
|
+
<span
|
|
833
|
+
data-product-card-badge=""
|
|
834
|
+
{...partAttrs('badge')}
|
|
835
|
+
style={{
|
|
836
|
+
alignSelf: 'flex-start',
|
|
837
|
+
padding: '2px 6px',
|
|
838
|
+
fontSize: '0.8em',
|
|
839
|
+
lineHeight: 1.4,
|
|
840
|
+
color: '#ffffff',
|
|
841
|
+
background: '#111827',
|
|
842
|
+
borderRadius: '2px',
|
|
843
|
+
...(isOverlayBadge
|
|
844
|
+
? { position: 'absolute', top: '8px', left: '8px' }
|
|
845
|
+
: null)
|
|
846
|
+
}}
|
|
847
|
+
>
|
|
848
|
+
{badgeLabel}
|
|
849
|
+
</span>
|
|
850
|
+
)}
|
|
851
|
+
|
|
852
|
+
{showName && (
|
|
853
|
+
<span
|
|
854
|
+
data-product-card-name=""
|
|
855
|
+
{...partAttrs('name')}
|
|
856
|
+
style={nameStyle}
|
|
857
|
+
>
|
|
858
|
+
{product.name}
|
|
859
|
+
</span>
|
|
860
|
+
)}
|
|
861
|
+
|
|
862
|
+
{showPrice && product.price && (
|
|
863
|
+
<span
|
|
864
|
+
data-product-card-price=""
|
|
865
|
+
{...partAttrs('price')}
|
|
866
|
+
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
|
867
|
+
>
|
|
868
|
+
{showRetailPrice && product.hasDiscount && product.retailPrice && (
|
|
869
|
+
<s
|
|
870
|
+
data-product-card-retail-price=""
|
|
871
|
+
{...partAttrs('list-price')}
|
|
872
|
+
style={{ opacity: 0.6, fontSize: '0.9em' }}
|
|
873
|
+
>
|
|
874
|
+
{product.retailPrice}
|
|
875
|
+
</s>
|
|
876
|
+
)}
|
|
877
|
+
<span {...partAttrs('sale-price')}>{product.price}</span>
|
|
878
|
+
</span>
|
|
879
|
+
)}
|
|
880
|
+
</NextLink>
|
|
881
|
+
);
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
const builtInCards = products.map(renderProductCard);
|
|
885
|
+
|
|
886
|
+
// With nothing authored this is the exact expression the block always used,
|
|
887
|
+
// element for element and key for key; the other two branches cannot be
|
|
888
|
+
// reached until a designer marks a child as a tile.
|
|
889
|
+
const gridChildren =
|
|
890
|
+
insertionTiles.length === 0 ? (
|
|
891
|
+
customCards ?? builtInCards
|
|
892
|
+
) : customCards ? (
|
|
893
|
+
<>
|
|
894
|
+
{customCards}
|
|
895
|
+
{insertionTiles}
|
|
896
|
+
</>
|
|
897
|
+
) : (
|
|
898
|
+
interleaveCells(builtInCards, insertionTiles, placedInsertions)
|
|
899
|
+
);
|
|
900
|
+
|
|
901
|
+
const grid = (
|
|
902
|
+
<div
|
|
903
|
+
data-product-grid=""
|
|
904
|
+
data-product-grid-id={gridId}
|
|
905
|
+
{...partAttrs('list')}
|
|
906
|
+
style={{ display: 'grid', gap: 'var(--product-grid-gap, 16px)' }}
|
|
907
|
+
>
|
|
908
|
+
<style>
|
|
909
|
+
{`[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
|
|
910
|
+
buildInsertionRules(gridId, placedInsertions, baseColumns, currentPage) +
|
|
911
|
+
`${MOBILE_MEDIA_QUERY_COMPACT}{[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsMobile},minmax(0,1fr));}` +
|
|
912
|
+
// Inside the same query as the mobile template, so a tile is measured
|
|
913
|
+
// against the column count it actually sits in.
|
|
914
|
+
buildInsertionRules(
|
|
915
|
+
gridId,
|
|
916
|
+
placedInsertions,
|
|
917
|
+
columnsMobile,
|
|
918
|
+
currentPage
|
|
919
|
+
) +
|
|
920
|
+
`}` +
|
|
921
|
+
// After the mobile query and only when a tablet count was authored,
|
|
922
|
+
// so a grid without one emits the two rules it always emitted.
|
|
923
|
+
(columnsTablet !== null
|
|
924
|
+
? `${TABLET_MEDIA_QUERY_COMPACT}{[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsTablet},minmax(0,1fr));}` +
|
|
925
|
+
buildInsertionRules(
|
|
926
|
+
gridId,
|
|
927
|
+
placedInsertions,
|
|
928
|
+
columnsTablet,
|
|
929
|
+
currentPage
|
|
930
|
+
) +
|
|
931
|
+
`}`
|
|
932
|
+
: '') +
|
|
933
|
+
// Last and unconditional, so an explicit choice outranks both the
|
|
934
|
+
// authored count and the mobile override above it.
|
|
935
|
+
(chosenColumns !== null
|
|
936
|
+
? `[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${chosenColumns},minmax(0,1fr));}` +
|
|
937
|
+
buildInsertionRules(
|
|
938
|
+
gridId,
|
|
939
|
+
placedInsertions,
|
|
940
|
+
chosenColumns,
|
|
941
|
+
currentPage
|
|
942
|
+
)
|
|
943
|
+
: '')}
|
|
944
|
+
</style>
|
|
945
|
+
|
|
946
|
+
{/* A designer who marked a child as the row layout gets their own card
|
|
947
|
+
repeated per product; otherwise the built-in card above renders. */}
|
|
948
|
+
{gridChildren}
|
|
949
|
+
</div>
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
// Without the switcher the block returns exactly the element it always has;
|
|
953
|
+
// with it, a fragment adds the toolbar without wrapping the grid in a node
|
|
954
|
+
// that would break the styles a brand already targets.
|
|
955
|
+
if (!densitySwitcher) return grid;
|
|
956
|
+
|
|
957
|
+
return (
|
|
958
|
+
<>
|
|
959
|
+
{densityPosition === 'after' ? null : densitySwitcher}
|
|
960
|
+
{grid}
|
|
961
|
+
{densityPosition === 'after' ? densitySwitcher : null}
|
|
962
|
+
</>
|
|
963
|
+
);
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
export default ProductGridBlock;
|