@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. 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;