@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,1523 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { useOverlay } from '../hooks/use-overlay';
5
+ import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { BlockRendererProps } from './block-renderer-registry';
7
+ import { hasItemTemplate, ItemTemplateList } from './item-template';
8
+ import {
9
+ DesignerPlaceholder,
10
+ ThemeProductImage,
11
+ getProductName,
12
+ pickProductField,
13
+ readToggle,
14
+ useThemeProduct
15
+ } from './product-block-utils';
16
+
17
+ const ALLOWED_ASPECT_RATIOS = ['1/1', '3/4', '4/3'];
18
+ const ALLOWED_OBJECT_FITS = ['cover', 'contain', 'fill', 'scale-down'];
19
+ const ALLOWED_THUMB_POSITIONS = ['bottom', 'top', 'left', 'right'];
20
+ const ALLOWED_LAYOUTS = ['carousel', 'grid'];
21
+
22
+ /** How far a one-finger drag must travel before it counts as a swipe. */
23
+ const SWIPE_THRESHOLD_PX = 48;
24
+ /** Below this a drag is a tap, not a gesture — thumbs wobble on every press. */
25
+ const DRAG_SLOP_PX = 4;
26
+
27
+ /**
28
+ * A merchant-authored image url, or '' when it is not one.
29
+ *
30
+ * The fallback photo and the high-resolution template are typed into the
31
+ * editor by a brand and both end up in `src`. Browsers strip whitespace and
32
+ * control characters while parsing a url, so a tab wedged into "java script:"
33
+ * still resolves to a live handler — the value is normalised the same way
34
+ * before it is judged.
35
+ */
36
+ const sanitizeImageUrl = (value: unknown): string => {
37
+ const url = String(value ?? '').trim();
38
+ if (!url) return '';
39
+
40
+ const probe = url.replace(/[\u0000-\u0020]/g, '').toLowerCase();
41
+ if (/^(?:javascript|vbscript):/.test(probe)) return '';
42
+
43
+ // An inline raster image is a legitimate fallback; everything else in the
44
+ // data: scheme is markup, and svg is the one image type that carries markup
45
+ // with it.
46
+ if (probe.startsWith('data:')) {
47
+ return probe.startsWith('data:image/') &&
48
+ !probe.startsWith('data:image/svg')
49
+ ? url
50
+ : '';
51
+ }
52
+
53
+ return url;
54
+ };
55
+
56
+ const clamp = (value: number, min: number, max: number): number =>
57
+ Math.min(max, Math.max(min, value));
58
+
59
+ const pointerDistance = (
60
+ a: { x: number; y: number },
61
+ b: { x: number; y: number }
62
+ ): number => Math.hypot(a.x - b.x, a.y - b.y);
63
+
64
+ const clampColumns = (value: unknown, fallback: number, max: number): number => {
65
+ const numeric = Math.round(Number(value));
66
+
67
+ return Number.isFinite(numeric) && numeric >= 1
68
+ ? Math.min(numeric, max)
69
+ : fallback;
70
+ };
71
+
72
+ /**
73
+ * Accessible copy is authored, not hardcoded, so a non-English storefront can
74
+ * translate it. `{name}` / `{index}` / `{total}` are the bindings a brand gets.
75
+ */
76
+ const fillTemplate = (
77
+ template: string,
78
+ values: { name: string; index: number; total: number }
79
+ ): string =>
80
+ template
81
+ .replace(/\{name\}/g, values.name)
82
+ .replace(/\{index\}/g, String(values.index))
83
+ .replace(/\{total\}/g, String(values.total));
84
+
85
+ const ChevronIcon = ({ direction }: { direction: 'prev' | 'next' }) => (
86
+ <svg
87
+ width="16"
88
+ height="16"
89
+ viewBox="0 0 24 24"
90
+ fill="none"
91
+ stroke="currentColor"
92
+ strokeWidth="1.8"
93
+ strokeLinecap="round"
94
+ strokeLinejoin="round"
95
+ aria-hidden="true"
96
+ >
97
+ <path d={direction === 'prev' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} />
98
+ </svg>
99
+ );
100
+
101
+ const CloseIcon = () => (
102
+ <svg
103
+ width="20"
104
+ height="20"
105
+ viewBox="0 0 24 24"
106
+ fill="none"
107
+ stroke="currentColor"
108
+ strokeWidth="1.8"
109
+ strokeLinecap="round"
110
+ aria-hidden="true"
111
+ >
112
+ <path d="M6 6l12 12M18 6L6 18" />
113
+ </svg>
114
+ );
115
+
116
+ /**
117
+ * Functional PDP block: main product image plus a thumbnail strip, or — when
118
+ * the grid layout is chosen — every product photo laid out in columns. The
119
+ * image CONTENT stays commerce-driven (product.images / product.image); the
120
+ * editor owns placement, aspect ratio, thumbnail visibility, the optional
121
+ * arrows and dots, and — through named parts — the styling of every piece.
122
+ *
123
+ * Enlargement is the second half of the block and every piece of it is off
124
+ * until a designer asks for it:
125
+ *
126
+ * - POINTER ZOOM magnifies a region under the cursor. Commerce sends ONE url
127
+ * per photo, so by default the magnified layer is the SAME asset drawn
128
+ * larger — it goes soft past the intrinsic resolution, and that is the
129
+ * honest limit of one url. `highResTemplate` is how a brand asks its CDN for
130
+ * a bigger one (`{url}` is the binding, e.g. `{url}?width=2000`); if that
131
+ * url fails to load the photo falls back to the asset commerce sent, once,
132
+ * remembered per url so a dead template cannot become a request loop.
133
+ * - The VIEWER is a full-screen dialog with every photo reachable from inside
134
+ * it, an optional thumbnail rail, pinch-to-zoom, drag-to-pan and swipe. Its
135
+ * close button is not a toggle: a viewer a phone user cannot leave is worse
136
+ * than no viewer.
137
+ * - The FALLBACK photo covers the three cases that actually happen — a product
138
+ * with no images, a url that 404s wherever it is first requested (the viewer
139
+ * included, since lazy grid cells are often never fetched before it opens),
140
+ * and an empty string in the payload.
141
+ *
142
+ * Parts: main, image, zoom-image, zoom-hint, viewer-trigger, viewer-backdrop,
143
+ * viewer, viewer-close, viewer-stage, viewer-image, viewer-arrow,
144
+ * viewer-counter, viewer-thumb-list, viewer-thumb, viewer-thumb-image, arrow,
145
+ * dot-list, dot, thumb-list, thumb, thumb-image, grid.
146
+ * Properties: layout, gridColumnsDesktop, gridColumnsMobile, showThumbs,
147
+ * aspectRatio, objectFit, thumbSize, thumbPosition, thumbLimit, thumbLabel,
148
+ * gap, showArrows, loopArrows, prevLabel, nextLabel, showDots, dotDirection,
149
+ * dotLabel, showCounter, counterLabel, enableZoom, zoomLevel, zoomHint,
150
+ * highResTemplate, enableViewer, viewerOpenLabel, viewerTitle,
151
+ * viewerCloseLabel, viewerShowArrows, viewerPrevLabel, viewerNextLabel,
152
+ * viewerShowThumbs, viewerThumbLabel, viewerShowCounter, viewerCounterLabel,
153
+ * viewerCloseOnEscape, viewerLockScroll, viewerGestures, viewerMaxZoom,
154
+ * fallbackImage, fallbackAlt.
155
+ */
156
+ const ProductGalleryBlock = ({
157
+ block,
158
+ placeholderId,
159
+ sectionId,
160
+ selectedBlockId = null,
161
+ currentBreakpoint = 'desktop',
162
+ isDesigner = false
163
+ }: BlockRendererProps) => {
164
+ const product = useThemeProduct();
165
+ const [activeIndex, setActiveIndex] = useState(0);
166
+ /** Which frame the pointer is magnifying, and where inside it, in percent. */
167
+ const [zoomPoint, setZoomPoint] = useState<{
168
+ index: number;
169
+ x: number;
170
+ y: number;
171
+ } | null>(null);
172
+ const [viewerOpen, setViewerOpen] = useState(false);
173
+ const [viewerScale, setViewerScale] = useState(1);
174
+ const [viewerPan, setViewerPan] = useState({ x: 0, y: 0 });
175
+ /** Photo urls the browser could not load, so the fallback takes over. */
176
+ const [failedUrls, setFailedUrls] = useState<Record<string, boolean>>({});
177
+ /** Photos whose high-resolution variant does not exist at the CDN. */
178
+ const [failedHighRes, setFailedHighRes] = useState<Record<string, boolean>>(
179
+ {}
180
+ );
181
+ // Only populated by the grid layout; the carousel has a single frame.
182
+ const cellRefs = useRef<Array<HTMLDivElement | null>>([]);
183
+ /** What opened the viewer, so focus can be handed back to exactly that. */
184
+ const openerRef = useRef<HTMLElement | null>(null);
185
+ /** Every hit area that can open the viewer, by photo index. */
186
+ const triggerRefs = useRef<Array<HTMLElement | null>>([]);
187
+ const gestureRef = useRef<{
188
+ distance: number;
189
+ scale: number;
190
+ panX: number;
191
+ panY: number;
192
+ x: number;
193
+ y: number;
194
+ } | null>(null);
195
+
196
+ const prop = (key: string, fallback: string) =>
197
+ String(
198
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
199
+ );
200
+
201
+ const layoutValue = prop('layout', 'carousel');
202
+ const layout = ALLOWED_LAYOUTS.includes(layoutValue)
203
+ ? layoutValue
204
+ : 'carousel';
205
+ const gridColumnsDesktop = clampColumns(
206
+ getResponsiveValue(
207
+ block.properties?.gridColumnsDesktop,
208
+ currentBreakpoint,
209
+ 2
210
+ ),
211
+ 2,
212
+ 4
213
+ );
214
+ const gridColumnsMobile = clampColumns(
215
+ getResponsiveValue(block.properties?.gridColumnsMobile, currentBreakpoint, 1),
216
+ 1,
217
+ 3
218
+ );
219
+ const showThumbs = readToggle(
220
+ block.properties?.showThumbs,
221
+ currentBreakpoint,
222
+ true
223
+ );
224
+ const aspectRatioValue = String(
225
+ getResponsiveValue(block.properties?.aspectRatio, currentBreakpoint, '3/4')
226
+ );
227
+ const aspectRatio = ALLOWED_ASPECT_RATIOS.includes(aspectRatioValue)
228
+ ? aspectRatioValue
229
+ : '3/4';
230
+ const objectFitValue = prop('objectFit', 'cover');
231
+ const objectFit = (
232
+ ALLOWED_OBJECT_FITS.includes(objectFitValue) ? objectFitValue : 'cover'
233
+ ) as React.CSSProperties['objectFit'];
234
+ const thumbPositionValue = prop('thumbPosition', 'bottom');
235
+ const thumbPosition = ALLOWED_THUMB_POSITIONS.includes(thumbPositionValue)
236
+ ? thumbPositionValue
237
+ : 'bottom';
238
+ const thumbSize = Number(
239
+ getResponsiveValue(block.properties?.thumbSize, currentBreakpoint, 64)
240
+ );
241
+ const thumbLimit = Number(
242
+ getResponsiveValue(block.properties?.thumbLimit, currentBreakpoint, 0)
243
+ );
244
+ const gap = Number(
245
+ getResponsiveValue(block.properties?.gap, currentBreakpoint, 8)
246
+ );
247
+ const thumbLabel = prop('thumbLabel', '{name} image {index}');
248
+ const showArrows = readToggle(
249
+ block.properties?.showArrows,
250
+ currentBreakpoint,
251
+ false
252
+ );
253
+ const loopArrows = readToggle(
254
+ block.properties?.loopArrows,
255
+ currentBreakpoint,
256
+ true
257
+ );
258
+ const prevLabel = prop('prevLabel', 'Previous image');
259
+ const nextLabel = prop('nextLabel', 'Next image');
260
+ const showDots = readToggle(
261
+ block.properties?.showDots,
262
+ currentBreakpoint,
263
+ false
264
+ );
265
+ const showCounter = readToggle(
266
+ block.properties?.showCounter,
267
+ currentBreakpoint,
268
+ false
269
+ );
270
+ const counterLabel = prop('counterLabel', '{index} / {total}');
271
+ const dotDirectionValue = prop('dotDirection', 'row');
272
+ const dotDirection = dotDirectionValue === 'column' ? 'column' : 'row';
273
+ const dotLabel = prop('dotLabel', 'Go to image {index}');
274
+
275
+ const enableZoom = readToggle(
276
+ block.properties?.enableZoom,
277
+ currentBreakpoint,
278
+ false
279
+ );
280
+ const zoomLevelValue = Number(
281
+ getResponsiveValue(block.properties?.zoomLevel, currentBreakpoint, 2)
282
+ );
283
+ const zoomLevel = Number.isFinite(zoomLevelValue)
284
+ ? clamp(zoomLevelValue, 1, 6)
285
+ : 2;
286
+ const zoomHint = prop('zoomHint', '');
287
+ /**
288
+ * Ignored unless it carries the binding: without `{url}` every photo would
289
+ * resolve to the same fixed address, which is a broken gallery rather than a
290
+ * sharper one.
291
+ */
292
+ const highResTemplateValue = prop('highResTemplate', '');
293
+ const highResTemplate = highResTemplateValue.includes('{url}')
294
+ ? highResTemplateValue
295
+ : '';
296
+
297
+ const enableViewer = readToggle(
298
+ block.properties?.enableViewer,
299
+ currentBreakpoint,
300
+ false
301
+ );
302
+ const viewerOpenLabel = prop('viewerOpenLabel', 'Enlarge image');
303
+ const viewerTitle = prop('viewerTitle', 'Product images');
304
+ const viewerCloseLabel = prop('viewerCloseLabel', 'Close');
305
+ const viewerShowArrows = readToggle(
306
+ block.properties?.viewerShowArrows,
307
+ currentBreakpoint,
308
+ true
309
+ );
310
+ const viewerPrevLabel = prop('viewerPrevLabel', 'Previous image');
311
+ const viewerNextLabel = prop('viewerNextLabel', 'Next image');
312
+ const viewerShowThumbs = readToggle(
313
+ block.properties?.viewerShowThumbs,
314
+ currentBreakpoint,
315
+ false
316
+ );
317
+ const viewerThumbLabel = prop('viewerThumbLabel', '{name} image {index}');
318
+ const viewerShowCounter = readToggle(
319
+ block.properties?.viewerShowCounter,
320
+ currentBreakpoint,
321
+ false
322
+ );
323
+ const viewerCounterLabel = prop('viewerCounterLabel', '{index} / {total}');
324
+ const viewerCloseOnEscape = readToggle(
325
+ block.properties?.viewerCloseOnEscape,
326
+ currentBreakpoint,
327
+ true
328
+ );
329
+ const viewerLockScroll = readToggle(
330
+ block.properties?.viewerLockScroll,
331
+ currentBreakpoint,
332
+ true
333
+ );
334
+ const viewerGestures = readToggle(
335
+ block.properties?.viewerGestures,
336
+ currentBreakpoint,
337
+ true
338
+ );
339
+ const viewerMaxZoomValue = Number(
340
+ getResponsiveValue(block.properties?.viewerMaxZoom, currentBreakpoint, 3)
341
+ );
342
+ const viewerMaxZoom = Number.isFinite(viewerMaxZoomValue)
343
+ ? clamp(viewerMaxZoomValue, 1, 6)
344
+ : 3;
345
+
346
+ // Merchant-typed and headed for `src`, so it is judged before it is used.
347
+ const fallbackImage = sanitizeImageUrl(prop('fallbackImage', ''));
348
+ const fallbackAlt = prop('fallbackAlt', '');
349
+
350
+ /**
351
+ * Where focus goes when the hit area it came from is gone by the time the
352
+ * viewer closes. A PDP swaps the photo list under the gallery when the
353
+ * shopper picks another colour, and it can do that with the viewer still up,
354
+ * taking that cell with it — the handover then lands on a detached node and
355
+ * a keyboard shopper is dropped at the top of the document, nowhere near the
356
+ * gallery they were reading. Read at close rather than captured at open, so
357
+ * it reports whichever hit area is still on the page.
358
+ */
359
+ const survivingTriggerRef = useMemo(
360
+ () => ({
361
+ get current(): HTMLElement | null {
362
+ return triggerRefs.current.find((node) => node?.isConnected) ?? null;
363
+ }
364
+ }),
365
+ []
366
+ );
367
+
368
+ /**
369
+ * Focus, Escape, the scroll lock and the book of pointers still down are
370
+ * page-wide concerns rather than gallery ones — a page can carry a second
371
+ * gallery, or a filter panel, and every one of those pieces of state is
372
+ * shared. The primitive owns them so the overlays queue instead of fighting
373
+ * over focus, so the page's own overflow survives more than one holder, and
374
+ * so a finger still down when the viewer closes cannot stay booked for the
375
+ * rest of the session.
376
+ */
377
+ const overlay = useOverlay<HTMLDivElement>({
378
+ open: enableViewer && viewerOpen,
379
+ onClose: () => setViewerOpen(false),
380
+ isDesigner,
381
+ closeOnEscape: viewerCloseOnEscape,
382
+ lockScroll: viewerLockScroll,
383
+ returnFocusRef: openerRef,
384
+ fallbackFocusRef: survivingTriggerRef,
385
+ // The pinch baseline was measured from pointers that are gone, so it has
386
+ // to be dropped with them or the next visit starts mid-gesture.
387
+ onDeactivate: () => {
388
+ gestureRef.current = null;
389
+ }
390
+ });
391
+
392
+ const productImages = useMemo(() => {
393
+ const list = pickProductField<ThemeProductImage[]>(product, 'images');
394
+ const resolved = (Array.isArray(list) ? list : [])
395
+ .map((item) => ({
396
+ url: String(item?.url || item?.image || '').trim(),
397
+ alt: String(item?.alt || '').trim()
398
+ }))
399
+ .filter((item) => item.url);
400
+
401
+ if (resolved.length > 0) return resolved;
402
+
403
+ const single = String(
404
+ pickProductField<string>(product, 'image') || ''
405
+ ).trim();
406
+
407
+ return single ? [{ url: single, alt: '' }] : [];
408
+ }, [product]);
409
+
410
+ /**
411
+ * A product with no photos at all — the payload carried none, or carried
412
+ * only empty strings, which the memo above drops for the same reason. With
413
+ * no fallback authored the block behaves exactly as it always has: nothing
414
+ * on the storefront, a hint on the canvas. With one, the gallery is that
415
+ * single photo, so the page keeps its shape instead of collapsing.
416
+ *
417
+ * The identity is deliberate: without a fallback this IS `productImages`,
418
+ * so nothing downstream can tell the two apart.
419
+ */
420
+ const fallbackApplied = fallbackImage !== '' && productImages.length === 0;
421
+ const images = fallbackApplied
422
+ ? [{ url: fallbackImage, alt: fallbackAlt }]
423
+ : productImages;
424
+
425
+ const imageCount = images.length;
426
+
427
+ /**
428
+ * The grid has no hero, so "which photo am I on" is a scroll position. The
429
+ * band across the viewport middle is what the indicator reports; the effect
430
+ * does nothing at all in the carousel layout, which is why that path is
431
+ * unchanged.
432
+ */
433
+ useEffect(() => {
434
+ // Cells write their ref by index and nothing ever shortens the array, so a
435
+ // product with fewer photos than the last render can leave a detached node
436
+ // sitting past the end. It survives .filter(Boolean) and shifts every index
437
+ // the observer reports, which points the indicator at the wrong photo.
438
+ cellRefs.current.length = imageCount;
439
+
440
+ if (layout !== 'grid' || !showDots || imageCount < 2) return;
441
+ if (typeof IntersectionObserver === 'undefined') return;
442
+
443
+ const nodes = cellRefs.current.filter(Boolean) as HTMLElement[];
444
+ if (nodes.length === 0) return;
445
+
446
+ const observer = new IntersectionObserver(
447
+ (entries) => {
448
+ entries.forEach((entry) => {
449
+ if (!entry.isIntersecting) return;
450
+
451
+ const index = nodes.indexOf(entry.target as HTMLElement);
452
+ if (index >= 0) setActiveIndex(index);
453
+ });
454
+ },
455
+ { rootMargin: '-50% 0px -50% 0px', threshold: 0 }
456
+ );
457
+
458
+ nodes.forEach((node) => observer.observe(node));
459
+
460
+ return () => observer.disconnect();
461
+ }, [layout, showDots, imageCount]);
462
+
463
+ useEffect(() => {
464
+ // A photo the shopper zoomed into stays zoomed when they swipe to the next
465
+ // one otherwise, which lands them somewhere in the middle of a picture
466
+ // they have not seen yet. Closing resets it for the next open.
467
+ setViewerScale(1);
468
+ setViewerPan((current) =>
469
+ current.x === 0 && current.y === 0 ? current : { x: 0, y: 0 }
470
+ );
471
+ }, [activeIndex, viewerOpen]);
472
+
473
+ const hasCustomThumbs = hasItemTemplate(block, currentBreakpoint);
474
+
475
+ // Both layouts scope a stylesheet to their own instance, so the id is shared.
476
+ const galleryId = String(block.id || 'gallery').replace(/[^a-zA-Z0-9_-]/g, '');
477
+
478
+ if (images.length === 0) {
479
+ return isDesigner ? (
480
+ <DesignerPlaceholder
481
+ label="Product gallery (no product images)"
482
+ attribute="data-product-gallery"
483
+ />
484
+ ) : null;
485
+ }
486
+
487
+ const safeThumbSize = Number.isFinite(thumbSize) && thumbSize > 0 ? thumbSize : 64;
488
+ const safeGap = Number.isFinite(gap) && gap >= 0 ? gap : 8;
489
+ const safeIndex = Math.min(Math.max(activeIndex, 0), images.length - 1);
490
+ const activeImage = images[safeIndex];
491
+ const productName = getProductName(product) || 'Product';
492
+ // 0 means "all": a limit is only a cap, never a way to hide the strip.
493
+ const safeThumbLimit =
494
+ Number.isFinite(thumbLimit) && thumbLimit > 0
495
+ ? Math.min(thumbLimit, images.length)
496
+ : images.length;
497
+ const thumbImages = images.slice(0, safeThumbLimit);
498
+
499
+ const isSideThumbs = thumbPosition === 'left' || thumbPosition === 'right';
500
+ const rootDirection =
501
+ thumbPosition === 'top'
502
+ ? 'column-reverse'
503
+ : thumbPosition === 'left'
504
+ ? 'row-reverse'
505
+ : thumbPosition === 'right'
506
+ ? 'row'
507
+ : 'column';
508
+
509
+ const atFirst = safeIndex === 0;
510
+ const atLast = safeIndex === images.length - 1;
511
+ const step = (delta: number) => {
512
+ const next = safeIndex + delta;
513
+ if (next < 0) return loopArrows ? images.length - 1 : 0;
514
+ if (next > images.length - 1) return loopArrows ? 0 : images.length - 1;
515
+ return next;
516
+ };
517
+
518
+ // In the carousel a dot swaps the hero; in the grid every photo is already
519
+ // on the page, so it scrolls to the one the shopper asked for.
520
+ const selectImage = (index: number) => {
521
+ setActiveIndex(index);
522
+
523
+ if (layout !== 'grid') return;
524
+
525
+ const node = cellRefs.current[index];
526
+ if (node && typeof node.scrollIntoView === 'function') {
527
+ node.scrollIntoView({ behavior: 'smooth', block: 'center' });
528
+ }
529
+ };
530
+
531
+ /**
532
+ * What one frame actually shows.
533
+ *
534
+ * `isFallback` is true either because the whole gallery IS the fallback (the
535
+ * product carried no photo) or because this photo's url failed to load — the
536
+ * two are the same thing to a shopper and to a designer styling the `empty`
537
+ * state, so they resolve to one answer here.
538
+ *
539
+ * With no fallback authored this is the identity: the src and alt are the
540
+ * exact expressions the block has always rendered.
541
+ */
542
+ const resolveFrame = (image: { url: string; alt: string }) => {
543
+ const failed =
544
+ fallbackImage !== '' &&
545
+ image.url !== fallbackImage &&
546
+ failedUrls[image.url] === true;
547
+
548
+ return {
549
+ src: failed ? fallbackImage : image.url,
550
+ alt: failed
551
+ ? fallbackAlt || image.alt || productName
552
+ : image.alt || productName,
553
+ isFallback: failed || fallbackApplied
554
+ };
555
+ };
556
+
557
+ /**
558
+ * Commerce sends one url per photo. Without a template the magnified layer
559
+ * is that same asset drawn larger — sharp up to its intrinsic resolution and
560
+ * soft past it. With one, the brand's CDN is asked for a bigger rendition;
561
+ * a rendition that does not exist is remembered so the next paint asks for
562
+ * the original instead of retrying forever.
563
+ */
564
+ const highResFor = (url: string): string => {
565
+ if (!highResTemplate || !url || failedHighRes[url]) return url;
566
+
567
+ return sanitizeImageUrl(highResTemplate.replace(/\{url\}/g, url)) || url;
568
+ };
569
+
570
+ /**
571
+ * Only attached once a fallback exists, so a gallery without one carries no
572
+ * handler at all. The fallback's own failure is ignored: there is nothing
573
+ * further to fall back to, and re-recording it would loop.
574
+ */
575
+ const handleImageError = fallbackImage
576
+ ? (url: string) => {
577
+ if (!url || url === fallbackImage) return;
578
+ setFailedUrls((current) =>
579
+ current[url] ? current : { ...current, [url]: true }
580
+ );
581
+ }
582
+ : undefined;
583
+
584
+ const handleHighResError = (url: string) => {
585
+ if (!url) return;
586
+ setFailedHighRes((current) =>
587
+ current[url] ? current : { ...current, [url]: true }
588
+ );
589
+ };
590
+
591
+ /**
592
+ * The viewer is regularly the FIRST thing to request a photo: grid cells
593
+ * past the first row are lazy, so one the shopper never scrolled to has
594
+ * never been fetched, and the viewer's request is the first anything hears
595
+ * of a dead url. Its failure therefore has to walk both steps rather than
596
+ * only the first — drop the larger rendition when that is what was asked
597
+ * for, and otherwise give up on the url itself so the fallback takes over
598
+ * and the frame reports `empty`. Without the second step the viewer sits on
599
+ * a broken image for the rest of the visit.
600
+ *
601
+ * The magnifier needs none of this: it draws over a frame whose own photo
602
+ * has already been requested, so a dead url is recorded before the
603
+ * magnified layer is ever painted.
604
+ */
605
+ const handleViewerImageError = (url: string) => {
606
+ if (highResFor(url) !== url) {
607
+ handleHighResError(url);
608
+ return;
609
+ }
610
+
611
+ handleImageError?.(url);
612
+ };
613
+
614
+ const openViewer = (index: number, opener: HTMLElement | null) => {
615
+ openerRef.current = opener;
616
+ setActiveIndex(index);
617
+ setViewerOpen(true);
618
+ };
619
+
620
+ /**
621
+ * The transparent hit area that opens the viewer. A sibling rather than a
622
+ * wrapper: the photo keeps the exact element and part it has always had, and
623
+ * the arrows — which come after it in the frame — stay on top of it.
624
+ */
625
+ const viewerTrigger = (index: number) =>
626
+ enableViewer ? (
627
+ <button
628
+ type="button"
629
+ ref={(node) => {
630
+ triggerRefs.current[index] = node;
631
+ }}
632
+ data-product-gallery-viewer-open=""
633
+ {...partAttrs('viewer-trigger')}
634
+ aria-haspopup="dialog"
635
+ aria-label={fillTemplate(viewerOpenLabel, {
636
+ name: productName,
637
+ index: index + 1,
638
+ total: imageCount
639
+ })}
640
+ onClick={(event) => openViewer(index, event.currentTarget)}
641
+ style={{
642
+ position: 'absolute',
643
+ inset: 0,
644
+ width: '100%',
645
+ height: '100%',
646
+ padding: 0,
647
+ border: 'none',
648
+ background: 'transparent',
649
+ cursor: 'zoom-in'
650
+ }}
651
+ />
652
+ ) : null;
653
+
654
+ /**
655
+ * The magnified layer. A separate image rather than a transform on the photo
656
+ * itself: the hero keeps its own element untouched, and while the larger
657
+ * asset is still downloading the shopper looks at the photo underneath
658
+ * instead of a blank frame.
659
+ *
660
+ * Touch pointers are ignored — a finger has no hover, and a tap there is the
661
+ * shopper asking for the viewer, not for a magnifier stuck under their thumb.
662
+ */
663
+ const zoomLayer = (index: number, image: { url: string; alt: string }) => {
664
+ if (!enableZoom || !zoomPoint || zoomPoint.index !== index) return null;
665
+
666
+ const frame = resolveFrame(image);
667
+
668
+ return (
669
+ <img
670
+ data-product-gallery-zoom=""
671
+ {...partAttrs('zoom-image', { empty: frame.isFallback })}
672
+ src={highResFor(frame.src)}
673
+ alt=""
674
+ aria-hidden="true"
675
+ onError={() => handleHighResError(frame.src)}
676
+ style={{
677
+ position: 'absolute',
678
+ inset: 0,
679
+ width: '100%',
680
+ height: '100%',
681
+ objectFit,
682
+ transform: `scale(${zoomLevel})`,
683
+ transformOrigin: `${zoomPoint.x}% ${zoomPoint.y}%`,
684
+ // The magnifier must never eat the click that opens the viewer.
685
+ pointerEvents: 'none'
686
+ }}
687
+ />
688
+ );
689
+ };
690
+
691
+ /**
692
+ * Magnify is a hover affordance, and a finger has no hover — so with the
693
+ * viewer switched off there is no enlargement a touch shopper can reach at
694
+ * all. The hint would still be drawn over the photo, and a brand that
695
+ * authored "Tap to enlarge" would be promising something a tap cannot
696
+ * deliver. On a pointer that cannot hover it is therefore not drawn.
697
+ *
698
+ * A media rule rather than a runtime check: the block is server-rendered and
699
+ * hover capability is not knowable until the client, so deciding this in JS
700
+ * would mean shipping one answer and then correcting it.
701
+ */
702
+ const hintNeedsHoverGate = zoomHint !== '' && enableZoom && !enableViewer;
703
+
704
+ const hintHoverGateNode = hintNeedsHoverGate ? (
705
+ <style>
706
+ {`@media (hover:none){[data-product-gallery-id="${galleryId}"] [data-part="zoom-hint"]{display:none;}}`}
707
+ </style>
708
+ ) : null;
709
+
710
+ const hintNode =
711
+ (enableZoom || enableViewer) && zoomHint ? (
712
+ <span
713
+ data-product-gallery-zoom-hint=""
714
+ {...partAttrs('zoom-hint')}
715
+ aria-hidden="true"
716
+ style={{
717
+ position: 'absolute',
718
+ left: '8px',
719
+ bottom: '8px',
720
+ padding: '2px 8px',
721
+ background: 'var(--product-gallery-hint-bg, rgba(255, 255, 255, 0.85))',
722
+ color: 'inherit',
723
+ pointerEvents: 'none'
724
+ }}
725
+ >
726
+ {zoomHint}
727
+ </span>
728
+ ) : null;
729
+
730
+ /**
731
+ * Pointer zoom is a mouse affordance, and in the designer the author's
732
+ * pointer belongs to the canvas — magnifying under it would fight every
733
+ * attempt to select or drag the block. The `open` state is still reachable
734
+ * there through the editor's part-state preview.
735
+ */
736
+ const zoomHandlers = (index: number) =>
737
+ enableZoom && !isDesigner
738
+ ? {
739
+ onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => {
740
+ if (event.pointerType === 'touch') return;
741
+
742
+ const rect = event.currentTarget.getBoundingClientRect();
743
+ if (!rect.width || !rect.height) return;
744
+
745
+ setZoomPoint({
746
+ index,
747
+ x: clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100),
748
+ y: clamp(((event.clientY - rect.top) / rect.height) * 100, 0, 100)
749
+ });
750
+ },
751
+ onPointerLeave: () => setZoomPoint(null)
752
+ }
753
+ : null;
754
+
755
+ /**
756
+ * Reported as `open` rather than `active`: the `active` state also carries
757
+ * the browser's own `:active`, so a brand styling "while the cursor is
758
+ * magnifying it" would get that styling on every mousedown — including on a
759
+ * gallery with magnify switched off entirely.
760
+ */
761
+ const isZooming = (index: number) =>
762
+ zoomPoint !== null && zoomPoint.index === index;
763
+
764
+ // A frame only needs a positioning context once something is layered over
765
+ // it; adding one unconditionally would change how a brand's own CSS lands.
766
+ const frameNeedsPosition = showArrows || enableViewer || enableZoom;
767
+
768
+ const arrowStyle = (side: 'left' | 'right'): React.CSSProperties => ({
769
+ position: 'absolute',
770
+ top: '50%',
771
+ [side]: '8px',
772
+ transform: 'translateY(-50%)',
773
+ display: 'inline-flex',
774
+ alignItems: 'center',
775
+ justifyContent: 'center',
776
+ width: '32px',
777
+ height: '32px',
778
+ padding: 0,
779
+ border: 'none',
780
+ borderRadius: '50%',
781
+ background: 'var(--product-gallery-arrow-bg, rgba(255, 255, 255, 0.85))',
782
+ color: 'inherit',
783
+ cursor: 'pointer'
784
+ });
785
+
786
+ // "2 / 10" — the position read as text rather than counted off a dot row.
787
+ // Both are allowed at once: a design that uses dots on desktop and the
788
+ // readout on a phone is a breakpoint choice, not an exclusive one.
789
+ const counterNode = showCounter && imageCount > 0 && (
790
+ <div
791
+ data-product-gallery-counter=""
792
+ {...partAttrs('counter')}
793
+ // Announced only when the shopper moves, not read out mid-swipe.
794
+ aria-live="polite"
795
+ aria-atomic="true"
796
+ style={{ textAlign: 'center' }}
797
+ >
798
+ {fillTemplate(counterLabel, {
799
+ name: productName,
800
+ index: safeIndex + 1,
801
+ total: imageCount
802
+ })}
803
+ </div>
804
+ );
805
+
806
+ const dotsNode = showDots && images.length > 1 && (
807
+ <div
808
+ data-product-gallery-dots=""
809
+ {...partAttrs('dot-list')}
810
+ style={{
811
+ display: 'flex',
812
+ alignItems: 'center',
813
+ justifyContent: 'center',
814
+ gap: '6px',
815
+ // Only written for the column indicator, so the existing dot row keeps
816
+ // the exact style attribute it has always rendered.
817
+ ...(dotDirection === 'column' ? { flexDirection: 'column' } : null)
818
+ }}
819
+ >
820
+ {images.map((image, index) => (
821
+ <button
822
+ key={`dot-${image.url}-${index}`}
823
+ type="button"
824
+ data-product-gallery-dot={index === safeIndex ? 'active' : 'default'}
825
+ {...partAttrs('dot', { selected: index === safeIndex })}
826
+ aria-label={fillTemplate(dotLabel, {
827
+ name: productName,
828
+ index: index + 1,
829
+ total: images.length
830
+ })}
831
+ aria-current={index === safeIndex}
832
+ onClick={() => selectImage(index)}
833
+ style={{
834
+ width: '8px',
835
+ height: '8px',
836
+ padding: 0,
837
+ border: 'none',
838
+ borderRadius: '50%',
839
+ cursor: 'pointer',
840
+ background:
841
+ index === safeIndex
842
+ ? 'currentColor'
843
+ : 'var(--product-gallery-dot, #d1d5db)'
844
+ }}
845
+ />
846
+ ))}
847
+ </div>
848
+ );
849
+
850
+ // A designer who marked a child as the thumbnail layout gets that layout
851
+ // repeated per image; otherwise the built-in thumbnail button renders.
852
+ const customThumbs = hasCustomThumbs ? (
853
+ <ItemTemplateList
854
+ block={block}
855
+ items={thumbImages.map((image, index) => ({
856
+ image: image.url,
857
+ alt: image.alt || productName,
858
+ name: productName,
859
+ index: index + 1,
860
+ total: images.length,
861
+ active: index === safeIndex
862
+ }))}
863
+ placeholderId={placeholderId}
864
+ sectionId={sectionId}
865
+ isDesigner={isDesigner}
866
+ selectedBlockId={selectedBlockId}
867
+ currentBreakpoint={currentBreakpoint}
868
+ designerPlaceholderCount={4}
869
+ />
870
+ ) : null;
871
+
872
+ const viewerFrame = resolveFrame(activeImage);
873
+
874
+ /**
875
+ * Pinch, pan and swipe share one pointer book: a phone reports each finger
876
+ * separately, and telling a two-finger pinch from a one-finger drag is only
877
+ * possible by counting the ones that are still down. The book belongs to the
878
+ * overlay because a finger still down when the viewer closes is never
879
+ * followed by a pointerup — kept per block, that id stayed booked for the
880
+ * rest of the session and every later one-finger drag was read as the second
881
+ * finger of a pinch that never started, so no swipe fired again.
882
+ */
883
+ const pointers = overlay.pointers;
884
+
885
+ const handleStagePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
886
+ if (!viewerGestures) return;
887
+
888
+ // Tracking also takes pointer capture, which keeps the gesture alive when
889
+ // the finger leaves the stage — exactly what a fast swipe does — and the
890
+ // overlay hands every capture back when it closes.
891
+ pointers.track(event);
892
+
893
+ const points = pointers.points();
894
+
895
+ gestureRef.current = {
896
+ distance:
897
+ points.length >= 2 ? pointerDistance(points[0], points[1]) : 0,
898
+ scale: viewerScale,
899
+ panX: viewerPan.x,
900
+ panY: viewerPan.y,
901
+ x: event.clientX,
902
+ y: event.clientY
903
+ };
904
+ };
905
+
906
+ const handleStagePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
907
+ const gesture = gestureRef.current;
908
+ if (!viewerGestures || !gesture || !pointers.has(event.pointerId)) return;
909
+
910
+ pointers.track(event);
911
+ const points = pointers.points();
912
+
913
+ if (points.length >= 2) {
914
+ const spread = pointerDistance(points[0], points[1]);
915
+ // The second finger can land a frame after the first, so the baseline is
916
+ // taken on the first move that actually has two.
917
+ if (!gesture.distance) {
918
+ gesture.distance = spread;
919
+ gesture.scale = viewerScale;
920
+ return;
921
+ }
922
+
923
+ setViewerScale(
924
+ clamp((gesture.scale * spread) / gesture.distance, 1, viewerMaxZoom)
925
+ );
926
+ return;
927
+ }
928
+
929
+ // One finger on a photo that is already magnified moves the photo; on one
930
+ // that is not, the same drag is on its way to being a swipe.
931
+ if (viewerScale > 1) {
932
+ setViewerPan({
933
+ x: gesture.panX + (event.clientX - gesture.x),
934
+ y: gesture.panY + (event.clientY - gesture.y)
935
+ });
936
+ }
937
+ };
938
+
939
+ const handleStagePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
940
+ const gesture = gestureRef.current;
941
+ pointers.release(event.pointerId);
942
+
943
+ if (!viewerGestures || !gesture) return;
944
+
945
+ if (pointers.size() > 0) {
946
+ // Coming out of a pinch with one finger still down: re-baseline against
947
+ // it so the photo does not jump when the drag continues.
948
+ const [remaining] = pointers.points();
949
+ gestureRef.current = {
950
+ distance: 0,
951
+ scale: viewerScale,
952
+ panX: viewerPan.x,
953
+ panY: viewerPan.y,
954
+ x: remaining.x,
955
+ y: remaining.y
956
+ };
957
+ return;
958
+ }
959
+
960
+ gestureRef.current = null;
961
+
962
+ const dx = event.clientX - gesture.x;
963
+ const dy = event.clientY - gesture.y;
964
+
965
+ if (Math.abs(dx) < DRAG_SLOP_PX && Math.abs(dy) < DRAG_SLOP_PX) return;
966
+ // A magnified photo is being panned, not swiped away.
967
+ if (viewerScale > 1 || images.length < 2) return;
968
+
969
+ if (Math.abs(dx) >= SWIPE_THRESHOLD_PX && Math.abs(dx) > Math.abs(dy)) {
970
+ setActiveIndex(step(dx < 0 ? 1 : -1));
971
+ }
972
+ };
973
+
974
+ /**
975
+ * The way back out of a pinch, and the only zoom a mouse has inside the
976
+ * viewer. Browsers synthesise `dblclick` from a double tap, so one handler
977
+ * serves both.
978
+ */
979
+ const handleStageDoubleClick = () => {
980
+ if (!viewerGestures) return;
981
+
982
+ setViewerScale((current) => (current > 1 ? 1 : viewerMaxZoom));
983
+ setViewerPan({ x: 0, y: 0 });
984
+ };
985
+
986
+ const viewerNode = enableViewer && viewerOpen && (
987
+ <div
988
+ data-product-gallery-viewer-root=""
989
+ style={{
990
+ position: 'fixed',
991
+ inset: 0,
992
+ zIndex: 1000,
993
+ display: 'flex'
994
+ }}
995
+ >
996
+ {/* The dark sheet, and only that. The dialog below is a full-viewport
997
+ sibling painted after it, so nothing a shopper taps ever reaches
998
+ this element — a dismiss handler here would be dead code that reads
999
+ like an affordance. The reachable ways out are the close button and
1000
+ Escape; the stage cannot be one of them, because the first half of a
1001
+ double-tap-to-zoom lands there. */}
1002
+ <div
1003
+ data-product-gallery-viewer-backdrop=""
1004
+ {...partAttrs('viewer-backdrop', { open: true })}
1005
+ style={{
1006
+ position: 'absolute',
1007
+ inset: 0,
1008
+ background: 'var(--product-gallery-viewer-bg, rgba(0, 0, 0, 0.92))'
1009
+ }}
1010
+ />
1011
+
1012
+ <div
1013
+ data-product-gallery-viewer=""
1014
+ {...partAttrs('viewer', { open: true })}
1015
+ /**
1016
+ * Carries the ref and the `aria-modal` claim, which the primitive
1017
+ * makes only where it is true: not in the designer, where focus is
1018
+ * deliberately left with the author, and not for the lower of two open
1019
+ * dialogs, which is not the one holding the page.
1020
+ */
1021
+ {...overlay.overlayProps}
1022
+ role="dialog"
1023
+ aria-label={viewerTitle}
1024
+ // Focus target of last resort, before the close button mounts.
1025
+ tabIndex={-1}
1026
+ style={{
1027
+ position: 'relative',
1028
+ display: 'flex',
1029
+ flexDirection: 'column',
1030
+ margin: 'auto',
1031
+ width: '100%',
1032
+ height: '100%',
1033
+ color: 'var(--product-gallery-viewer-color, #ffffff)',
1034
+ outline: 'none'
1035
+ }}
1036
+ >
1037
+ {/* Not a toggle. Every other way out is a designer choice, and a
1038
+ viewer a phone user cannot leave is worse than no viewer. */}
1039
+ <button
1040
+ type="button"
1041
+ data-product-gallery-viewer-close=""
1042
+ {...partAttrs('viewer-close')}
1043
+ aria-label={viewerCloseLabel}
1044
+ onClick={() => setViewerOpen(false)}
1045
+ style={{
1046
+ position: 'absolute',
1047
+ top: '12px',
1048
+ right: '12px',
1049
+ zIndex: 2,
1050
+ display: 'inline-flex',
1051
+ alignItems: 'center',
1052
+ justifyContent: 'center',
1053
+ width: '40px',
1054
+ height: '40px',
1055
+ padding: 0,
1056
+ border: 'none',
1057
+ background: 'transparent',
1058
+ color: 'inherit',
1059
+ cursor: 'pointer'
1060
+ }}
1061
+ >
1062
+ <CloseIcon />
1063
+ </button>
1064
+
1065
+ <div
1066
+ data-product-gallery-viewer-stage=""
1067
+ {...partAttrs('viewer-stage', { active: viewerScale > 1 })}
1068
+ onPointerDown={handleStagePointerDown}
1069
+ onPointerMove={handleStagePointerMove}
1070
+ onPointerUp={handleStagePointerUp}
1071
+ onPointerCancel={handleStagePointerUp}
1072
+ onDoubleClick={handleStageDoubleClick}
1073
+ style={{
1074
+ position: 'relative',
1075
+ flex: '1 1 auto',
1076
+ minHeight: 0,
1077
+ overflow: 'hidden',
1078
+ display: 'flex',
1079
+ alignItems: 'center',
1080
+ justifyContent: 'center',
1081
+ // Without this the browser claims the pinch and the drag for its
1082
+ // own page zoom and scroll, and neither gesture ever arrives.
1083
+ touchAction: viewerGestures ? 'none' : undefined
1084
+ }}
1085
+ >
1086
+ <img
1087
+ data-product-gallery-viewer-image=""
1088
+ {...partAttrs('viewer-image', {
1089
+ empty: viewerFrame.isFallback,
1090
+ active: viewerScale > 1
1091
+ })}
1092
+ src={highResFor(viewerFrame.src)}
1093
+ alt={viewerFrame.alt}
1094
+ draggable={false}
1095
+ onError={() => handleViewerImageError(viewerFrame.src)}
1096
+ style={{
1097
+ maxWidth: '100%',
1098
+ maxHeight: '100%',
1099
+ objectFit: 'contain',
1100
+ transform: `translate(${viewerPan.x}px, ${viewerPan.y}px) scale(${viewerScale})`,
1101
+ // The photo is the thing being dragged; the stage above owns the
1102
+ // pointer so a drag that starts on the image is not two gestures.
1103
+ pointerEvents: 'none',
1104
+ userSelect: 'none'
1105
+ }}
1106
+ />
1107
+
1108
+ {viewerShowArrows && images.length > 1 && (
1109
+ <>
1110
+ <button
1111
+ type="button"
1112
+ data-product-gallery-viewer-arrow="prev"
1113
+ {...partAttrs('viewer-arrow', {
1114
+ disabled: !loopArrows && atFirst
1115
+ })}
1116
+ aria-label={viewerPrevLabel}
1117
+ disabled={!loopArrows && atFirst}
1118
+ onClick={() => setActiveIndex(step(-1))}
1119
+ style={arrowStyle('left')}
1120
+ >
1121
+ <ChevronIcon direction="prev" />
1122
+ </button>
1123
+ <button
1124
+ type="button"
1125
+ data-product-gallery-viewer-arrow="next"
1126
+ {...partAttrs('viewer-arrow', {
1127
+ disabled: !loopArrows && atLast
1128
+ })}
1129
+ aria-label={viewerNextLabel}
1130
+ disabled={!loopArrows && atLast}
1131
+ onClick={() => setActiveIndex(step(1))}
1132
+ style={arrowStyle('right')}
1133
+ >
1134
+ <ChevronIcon direction="next" />
1135
+ </button>
1136
+ </>
1137
+ )}
1138
+ </div>
1139
+
1140
+ {viewerShowCounter && (
1141
+ <div
1142
+ data-product-gallery-viewer-counter=""
1143
+ {...partAttrs('viewer-counter')}
1144
+ aria-live="polite"
1145
+ aria-atomic="true"
1146
+ style={{ flex: '0 0 auto', padding: '8px', textAlign: 'center' }}
1147
+ >
1148
+ {fillTemplate(viewerCounterLabel, {
1149
+ name: productName,
1150
+ index: safeIndex + 1,
1151
+ total: imageCount
1152
+ })}
1153
+ </div>
1154
+ )}
1155
+
1156
+ {viewerShowThumbs && images.length > 1 && (
1157
+ <div
1158
+ data-product-gallery-viewer-thumbs=""
1159
+ {...partAttrs('viewer-thumb-list')}
1160
+ style={{
1161
+ display: 'flex',
1162
+ flex: '0 0 auto',
1163
+ gap: `${safeGap}px`,
1164
+ padding: '8px',
1165
+ justifyContent: 'center',
1166
+ overflowX: 'auto',
1167
+ flexWrap: 'nowrap'
1168
+ }}
1169
+ >
1170
+ {images.map((image, index) => {
1171
+ const thumbFrame = resolveFrame(image);
1172
+
1173
+ return (
1174
+ <button
1175
+ key={`viewer-thumb-${image.url}-${index}`}
1176
+ type="button"
1177
+ data-product-gallery-viewer-thumb={
1178
+ index === safeIndex ? 'active' : 'default'
1179
+ }
1180
+ {...partAttrs('viewer-thumb', {
1181
+ selected: index === safeIndex,
1182
+ empty: thumbFrame.isFallback
1183
+ })}
1184
+ aria-label={fillTemplate(viewerThumbLabel, {
1185
+ name: productName,
1186
+ index: index + 1,
1187
+ total: imageCount
1188
+ })}
1189
+ aria-current={index === safeIndex}
1190
+ onClick={() => setActiveIndex(index)}
1191
+ style={{
1192
+ flex: '0 0 auto',
1193
+ width: `${safeThumbSize}px`,
1194
+ height: `${safeThumbSize}px`,
1195
+ padding: 0,
1196
+ cursor: 'pointer',
1197
+ overflow: 'hidden',
1198
+ background: 'transparent',
1199
+ border:
1200
+ index === safeIndex
1201
+ ? '1px solid var(--product-gallery-thumb-active, currentColor)'
1202
+ : '1px solid var(--product-gallery-thumb-border, rgba(255, 255, 255, 0.4))'
1203
+ }}
1204
+ >
1205
+ <img
1206
+ src={thumbFrame.src}
1207
+ alt=""
1208
+ loading="lazy"
1209
+ {...partAttrs('viewer-thumb-image')}
1210
+ onError={
1211
+ handleImageError
1212
+ ? () => handleImageError(image.url)
1213
+ : undefined
1214
+ }
1215
+ style={{
1216
+ display: 'block',
1217
+ width: '100%',
1218
+ height: '100%',
1219
+ objectFit: 'cover'
1220
+ }}
1221
+ />
1222
+ </button>
1223
+ );
1224
+ })}
1225
+ </div>
1226
+ )}
1227
+ </div>
1228
+ </div>
1229
+ );
1230
+
1231
+ if (layout === 'grid') {
1232
+ // Column counts are block PROPERTIES, and properties resolve against the
1233
+ // designer breakpoint only — on the storefront currentBreakpoint is always
1234
+ // 'desktop'. A scoped rule with the same 1023px cutoff the theme CSS
1235
+ // generator uses keeps real mobile viewports in sync with the editor.
1236
+ const baseColumns =
1237
+ currentBreakpoint === 'mobile' ? gridColumnsMobile : gridColumnsDesktop;
1238
+
1239
+ // The same authored row the thumbnail strip would repeat, given every
1240
+ // photo: a designer who built one keeps it in this layout too.
1241
+ const customCells = hasCustomThumbs ? (
1242
+ <ItemTemplateList
1243
+ block={block}
1244
+ items={images.map((image, index) => ({
1245
+ image: image.url,
1246
+ alt: image.alt || productName,
1247
+ name: productName,
1248
+ index: index + 1,
1249
+ total: images.length,
1250
+ active: index === safeIndex
1251
+ }))}
1252
+ placeholderId={placeholderId}
1253
+ sectionId={sectionId}
1254
+ isDesigner={isDesigner}
1255
+ selectedBlockId={selectedBlockId}
1256
+ currentBreakpoint={currentBreakpoint}
1257
+ designerPlaceholderCount={4}
1258
+ />
1259
+ ) : null;
1260
+
1261
+ // The indicator follows the scroll through refs only the built-in cells
1262
+ // carry, and it scrolls to a cell the same way. An authored cell template
1263
+ // exposes neither, so the dots there would render and do nothing at all —
1264
+ // better no indicator than a dead one.
1265
+ const gridDots = customCells ? null : dotsNode;
1266
+ // Same reason as the dots: without the built-in cells there is nothing to
1267
+ // observe, so the number would sit on 1 for the whole scroll.
1268
+ const gridCounter = customCells ? null : counterNode;
1269
+
1270
+ return (
1271
+ <div
1272
+ data-product-gallery=""
1273
+ data-product-gallery-layout="grid"
1274
+ data-product-gallery-id={galleryId}
1275
+ style={{
1276
+ // The indicator is placed with the position controls on the dot row,
1277
+ // which need a positioned ancestor to resolve against.
1278
+ position:
1279
+ (showDots || showCounter) && !customCells ? 'relative' : undefined,
1280
+ width: '100%'
1281
+ }}
1282
+ >
1283
+ <style>
1284
+ {`[data-product-gallery-id="${galleryId}"] [data-part="grid"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
1285
+ `@media (max-width:1023px){[data-product-gallery-id="${galleryId}"] [data-part="grid"]{grid-template-columns:repeat(${gridColumnsMobile},minmax(0,1fr));}}`}
1286
+ </style>
1287
+
1288
+ {hintHoverGateNode}
1289
+
1290
+ <div
1291
+ {...partAttrs('grid')}
1292
+ style={{ display: 'grid', gap: `${safeGap}px`, width: '100%' }}
1293
+ >
1294
+ {customCells ??
1295
+ images.map((image, index) => {
1296
+ const frame = resolveFrame(image);
1297
+
1298
+ return (
1299
+ <div
1300
+ key={`${image.url}-${index}`}
1301
+ ref={(node) => {
1302
+ cellRefs.current[index] = node;
1303
+ }}
1304
+ data-product-gallery-cell={index === safeIndex ? 'active' : 'default'}
1305
+ {...partAttrs('main', {
1306
+ selected: index === safeIndex,
1307
+ open: isZooming(index),
1308
+ empty: frame.isFallback
1309
+ })}
1310
+ {...zoomHandlers(index)}
1311
+ style={{
1312
+ // Undefined until something is layered over the cell, so the
1313
+ // shipped grid keeps the exact style attribute it had.
1314
+ position: frameNeedsPosition ? 'relative' : undefined,
1315
+ aspectRatio,
1316
+ overflow: 'hidden',
1317
+ background: 'var(--product-gallery-bg, #f3f4f6)'
1318
+ }}
1319
+ >
1320
+ <img
1321
+ src={frame.src}
1322
+ alt={frame.alt}
1323
+ {...partAttrs('image', { empty: frame.isFallback })}
1324
+ // The first rows are above the fold on a PDP grid.
1325
+ loading={index < baseColumns ? 'eager' : 'lazy'}
1326
+ onError={
1327
+ handleImageError
1328
+ ? () => handleImageError(image.url)
1329
+ : undefined
1330
+ }
1331
+ style={{
1332
+ display: 'block',
1333
+ width: '100%',
1334
+ height: '100%',
1335
+ objectFit
1336
+ }}
1337
+ />
1338
+
1339
+ {zoomLayer(index, image)}
1340
+ {viewerTrigger(index)}
1341
+ {hintNode}
1342
+ </div>
1343
+ );
1344
+ })}
1345
+ </div>
1346
+
1347
+ {gridDots}
1348
+ {gridCounter}
1349
+ {viewerNode}
1350
+ </div>
1351
+ );
1352
+ }
1353
+
1354
+ return (
1355
+ <div
1356
+ data-product-gallery=""
1357
+ data-product-gallery-thumb-position={thumbPosition}
1358
+ // Only written when the hover gate above needs something to scope to, so
1359
+ // a gallery without one keeps the exact attributes it has always had.
1360
+ data-product-gallery-id={hintNeedsHoverGate ? galleryId : undefined}
1361
+ style={{
1362
+ display: 'flex',
1363
+ flexDirection: rootDirection,
1364
+ alignItems: isSideThumbs ? 'flex-start' : undefined,
1365
+ gap: `${safeGap}px`,
1366
+ width: '100%'
1367
+ }}
1368
+ >
1369
+ {hintHoverGateNode}
1370
+
1371
+ <div
1372
+ data-product-gallery-main=""
1373
+ {...partAttrs('main', {
1374
+ open: isZooming(safeIndex),
1375
+ empty: viewerFrame.isFallback
1376
+ })}
1377
+ {...zoomHandlers(safeIndex)}
1378
+ style={{
1379
+ // Only a gallery with arrows, zoom or the viewer needs a positioning
1380
+ // context; adding it unconditionally would change how a brand's own
1381
+ // CSS lands.
1382
+ position: frameNeedsPosition ? 'relative' : undefined,
1383
+ width: isSideThumbs ? undefined : '100%',
1384
+ flex: isSideThumbs ? '1 1 auto' : undefined,
1385
+ minWidth: isSideThumbs ? 0 : undefined,
1386
+ aspectRatio,
1387
+ overflow: 'hidden',
1388
+ background: 'var(--product-gallery-bg, #f3f4f6)'
1389
+ }}
1390
+ >
1391
+ <img
1392
+ key={activeImage.url}
1393
+ src={viewerFrame.src}
1394
+ alt={viewerFrame.alt}
1395
+ {...partAttrs('image', { empty: viewerFrame.isFallback })}
1396
+ // The PDP hero is above the fold, so the first frame is never lazy.
1397
+ loading={safeIndex === 0 ? 'eager' : 'lazy'}
1398
+ onError={
1399
+ handleImageError
1400
+ ? () => handleImageError(activeImage.url)
1401
+ : undefined
1402
+ }
1403
+ style={{
1404
+ display: 'block',
1405
+ width: '100%',
1406
+ height: '100%',
1407
+ objectFit
1408
+ }}
1409
+ />
1410
+
1411
+ {zoomLayer(safeIndex, activeImage)}
1412
+ {viewerTrigger(safeIndex)}
1413
+ {hintNode}
1414
+
1415
+ {showArrows && images.length > 1 && (
1416
+ <>
1417
+ <button
1418
+ type="button"
1419
+ data-product-gallery-arrow="prev"
1420
+ {...partAttrs('arrow', { disabled: !loopArrows && atFirst })}
1421
+ aria-label={prevLabel}
1422
+ disabled={!loopArrows && atFirst}
1423
+ onClick={() => setActiveIndex(step(-1))}
1424
+ style={arrowStyle('left')}
1425
+ >
1426
+ <ChevronIcon direction="prev" />
1427
+ </button>
1428
+ <button
1429
+ type="button"
1430
+ data-product-gallery-arrow="next"
1431
+ {...partAttrs('arrow', { disabled: !loopArrows && atLast })}
1432
+ aria-label={nextLabel}
1433
+ disabled={!loopArrows && atLast}
1434
+ onClick={() => setActiveIndex(step(1))}
1435
+ style={arrowStyle('right')}
1436
+ >
1437
+ <ChevronIcon direction="next" />
1438
+ </button>
1439
+ </>
1440
+ )}
1441
+ </div>
1442
+
1443
+ {dotsNode}
1444
+ {counterNode}
1445
+
1446
+ {showThumbs && images.length > 1 && (
1447
+ <div
1448
+ data-product-gallery-thumbs=""
1449
+ {...partAttrs('thumb-list')}
1450
+ style={{
1451
+ display: 'flex',
1452
+ flexDirection: isSideThumbs ? 'column' : 'row',
1453
+ gap: `${safeGap}px`,
1454
+ overflowX: isSideThumbs ? 'visible' : 'auto',
1455
+ overflowY: isSideThumbs ? 'auto' : 'visible',
1456
+ flexWrap: 'nowrap'
1457
+ }}
1458
+ >
1459
+ {customThumbs ??
1460
+ thumbImages.map((image, index) => {
1461
+ const thumbFrame = resolveFrame(image);
1462
+
1463
+ return (
1464
+ <button
1465
+ key={`${image.url}-${index}`}
1466
+ type="button"
1467
+ data-product-gallery-thumb={
1468
+ index === safeIndex ? 'active' : 'default'
1469
+ }
1470
+ {...partAttrs('thumb', {
1471
+ selected: index === safeIndex,
1472
+ empty: thumbFrame.isFallback
1473
+ })}
1474
+ aria-label={fillTemplate(thumbLabel, {
1475
+ name: productName,
1476
+ index: index + 1,
1477
+ total: images.length
1478
+ })}
1479
+ aria-current={index === safeIndex}
1480
+ onClick={() => setActiveIndex(index)}
1481
+ style={{
1482
+ flex: '0 0 auto',
1483
+ width: `${safeThumbSize}px`,
1484
+ height: `${safeThumbSize}px`,
1485
+ padding: 0,
1486
+ cursor: 'pointer',
1487
+ overflow: 'hidden',
1488
+ background: 'transparent',
1489
+ border:
1490
+ index === safeIndex
1491
+ ? '1px solid var(--product-gallery-thumb-active, currentColor)'
1492
+ : '1px solid var(--product-gallery-thumb-border, #e5e7eb)'
1493
+ }}
1494
+ >
1495
+ <img
1496
+ src={thumbFrame.src}
1497
+ alt=""
1498
+ loading="lazy"
1499
+ {...partAttrs('thumb-image', { empty: thumbFrame.isFallback })}
1500
+ onError={
1501
+ handleImageError
1502
+ ? () => handleImageError(image.url)
1503
+ : undefined
1504
+ }
1505
+ style={{
1506
+ display: 'block',
1507
+ width: '100%',
1508
+ height: '100%',
1509
+ objectFit: 'cover'
1510
+ }}
1511
+ />
1512
+ </button>
1513
+ );
1514
+ })}
1515
+ </div>
1516
+ )}
1517
+
1518
+ {viewerNode}
1519
+ </div>
1520
+ );
1521
+ };
1522
+
1523
+ export default ProductGalleryBlock;