@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,923 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+ import { usePathname, useSearchParams } from 'next/navigation';
6
+ import { useLocalization } from '@akinon/next/hooks';
7
+ import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
8
+ import { hasItemTemplate, ItemTemplateList } from './item-template';
9
+ import { BlockRendererProps } from './block-renderer-registry';
10
+ import {
11
+ buildListingHref,
12
+ listingPlaceholderStyle,
13
+ readToggle,
14
+ useListingContext
15
+ } from './listing-context';
16
+
17
+ const linkStyle = (isCurrent: boolean): React.CSSProperties => ({
18
+ display: 'inline-flex',
19
+ alignItems: 'center',
20
+ justifyContent: 'center',
21
+ minWidth: '32px',
22
+ padding: '4px 8px',
23
+ color: 'inherit',
24
+ textDecoration: 'none',
25
+ border: '1px solid currentColor',
26
+ borderRadius: 'inherit',
27
+ opacity: isCurrent ? 1 : 0.7,
28
+ fontWeight: isCurrent ? 600 : 400
29
+ });
30
+
31
+ type AppendTrigger = 'button' | 'scroll';
32
+ const APPEND_TRIGGERS: AppendTrigger[] = ['button', 'scroll'];
33
+
34
+ type StatusPosition = 'before' | 'after';
35
+ const STATUS_POSITIONS: StatusPosition[] = ['before', 'after'];
36
+
37
+ /**
38
+ * A brand can type any selector into "List Container", so every lookup is
39
+ * wrapped: an unfinished selector throws a SyntaxError that would otherwise
40
+ * take the whole listing down while they are still typing it.
41
+ */
42
+ const safeQuery = (scope: ParentNode, selector: string): Element | null => {
43
+ try {
44
+ return scope.querySelector(selector);
45
+ } catch {
46
+ return null;
47
+ }
48
+ };
49
+
50
+ const safeQueryAll = (scope: ParentNode, selector: string): Element[] => {
51
+ try {
52
+ return Array.from(scope.querySelectorAll(selector));
53
+ } catch {
54
+ return [];
55
+ }
56
+ };
57
+
58
+ /**
59
+ * The list this pagination belongs to: the nearest ancestor that contains a
60
+ * match wins, so a page carrying two listings appends into the one the block
61
+ * actually sits under rather than into whichever grid happens to be first in
62
+ * the document.
63
+ */
64
+ const findAppendTarget = (
65
+ from: Element | null,
66
+ selector: string
67
+ ): Element | null => {
68
+ let scope: Element | null = from ? from.parentElement : null;
69
+
70
+ while (scope) {
71
+ const match = safeQuery(scope, selector);
72
+ if (match) return match;
73
+ scope = scope.parentElement;
74
+ }
75
+
76
+ return safeQuery(document, selector);
77
+ };
78
+
79
+ /** Which of the page's lists the target is, so the fetched page is read the same way. */
80
+ const indexOfTarget = (target: Element, selector: string): number => {
81
+ const index = safeQueryAll(document, selector).indexOf(target);
82
+ return index < 0 ? 0 : index;
83
+ };
84
+
85
+ /**
86
+ * What may cross from a fetched page into the live document.
87
+ *
88
+ * An allowlist, not a skip list. A skip list only ever excludes what somebody
89
+ * thought of, and it is checked one level deep while `importNode(node, true)`
90
+ * copies the entire subtree: a `<script>` two levels down inside a card rode
91
+ * straight through. That script is cloned out of a `DOMParser` document, where
92
+ * scripting is off and it therefore never started, so it RUNS the moment it is
93
+ * inserted here. A nested `<style>` is the same problem the top level always
94
+ * had — a lottie, an announcement bar or a recommendation strip inside a card
95
+ * emits one, and rules re-declared after the live ones outrank them and undo
96
+ * the shopper's density choice.
97
+ *
98
+ * Everything below is inert: it draws, it does not execute and it does not
99
+ * pull in a document of its own. What is absent is as much the point — script,
100
+ * style, link, template, iframe, object, embed, noscript, slot, and anything a
101
+ * brand invented, since a custom element runs a constructor on upgrade.
102
+ */
103
+ const GRAFT_ALLOWED_TAGS = new Set([
104
+ // Flow and text.
105
+ 'a', 'abbr', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', 'blockquote',
106
+ 'br', 'caption', 'cite', 'code', 'col', 'colgroup', 'data', 'dd', 'del',
107
+ 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer',
108
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'ins',
109
+ 'kbd', 'li', 'main', 'mark', 'nav', 'ol', 'p', 'pre', 'q', 'rp', 'rt',
110
+ 'ruby', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary',
111
+ 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u',
112
+ 'ul', 'var', 'wbr',
113
+ // Media. The card's image is the whole point of grafting the row at all.
114
+ 'audio', 'canvas', 'img', 'picture', 'source', 'track', 'video',
115
+ // Controls a card can draw. They arrive inert whatever happens — React owns
116
+ // none of this DOM — but dropping them would leave an appended card looking
117
+ // unlike the one directly above it.
118
+ 'button', 'fieldset', 'form', 'input', 'label', 'legend', 'meter',
119
+ 'optgroup', 'option', 'output', 'progress', 'select', 'textarea',
120
+ // SVG: icons, badges, ratings. Lower-cased on both sides, so `clipPath` and
121
+ // `linearGradient` are compared the same way an HTML tag name is.
122
+ 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'feblend', 'fecolormatrix',
123
+ 'fecomposite', 'fedropshadow', 'feflood', 'fegaussianblur', 'femerge',
124
+ 'femergenode', 'feoffset', 'filter', 'g', 'image', 'line', 'lineargradient',
125
+ 'marker', 'mask', 'path', 'pattern', 'polygon', 'polyline',
126
+ 'radialgradient', 'rect', 'stop', 'svg', 'symbol', 'text', 'textpath',
127
+ 'title', 'tspan', 'use'
128
+ ]);
129
+
130
+ const SCRIPT_SCHEME = /^(?:javascript|vbscript):/;
131
+
132
+ /**
133
+ * Browsers strip whitespace and control characters while parsing a url, so a
134
+ * tab wedged into "javascript:" still resolves to a live handler; the value
135
+ * is normalised the same way before it is judged.
136
+ */
137
+ const isScriptUrl = (value: string): boolean =>
138
+ SCRIPT_SCHEME.test(value.replace(/[\u0000-\u0020]/g, '').toLowerCase());
139
+
140
+ /**
141
+ * Attributes carrying text a shopper reads rather than something the browser
142
+ * resolves.
143
+ *
144
+ * "JavaScript: The Good Parts" is a product a bookshop really sells, and it
145
+ * normalises to a script scheme: judging EVERY attribute took the `alt` off
146
+ * that card — the only description a screen reader has for it — and took it on
147
+ * the appended page only, so the same product read differently above and below
148
+ * the fold. Nothing named here is ever fetched, navigated or executed, so no
149
+ * scheme can act from inside one. Everything not named here is still judged,
150
+ * which keeps this deny-by-default: an attribute nobody thought of is scrubbed,
151
+ * and that is the property that caught the script url in the first place.
152
+ */
153
+ const TEXT_ONLY_ATTRIBUTES = new Set(['alt', 'title', 'placeholder']);
154
+
155
+ const isTextOnlyAttribute = (name: string): boolean =>
156
+ TEXT_ONLY_ATTRIBUTES.has(name) || name.startsWith('aria-');
157
+
158
+ /**
159
+ * Strips what executes out of a subtree while it is still in the inert parsed
160
+ * document, so the clone that reaches the live DOM never carried it.
161
+ */
162
+ const sanitizeGraft = (element: Element) => {
163
+ Array.from(element.attributes).forEach((attribute) => {
164
+ const name = attribute.name.toLowerCase();
165
+ // `on*` is a handler; `is` upgrades a plain tag into a custom element and
166
+ // runs its constructor; a `javascript:` url is one click or one load from
167
+ // the same thing.
168
+ if (
169
+ name.startsWith('on') ||
170
+ name === 'is' ||
171
+ (!isTextOnlyAttribute(name) && isScriptUrl(attribute.value))
172
+ ) {
173
+ element.removeAttribute(attribute.name);
174
+ }
175
+ });
176
+
177
+ Array.from(element.children).forEach((child) => {
178
+ if (GRAFT_ALLOWED_TAGS.has(child.tagName.toLowerCase())) {
179
+ sanitizeGraft(child);
180
+ return;
181
+ }
182
+
183
+ child.remove();
184
+ });
185
+ };
186
+
187
+ /**
188
+ * Reads the rows of page N out of that page's own server response.
189
+ *
190
+ * Returns null when the response carried no list at all (a redirect, an error
191
+ * page, a login wall) — that is a failure the shopper has to be told about.
192
+ * An empty array means the page rendered but held no rows, which is the end of
193
+ * the list, not an error.
194
+ */
195
+ const extractAppendNodes = (
196
+ html: string,
197
+ selector: string,
198
+ targetIndex: number
199
+ ): Element[] | null => {
200
+ const parsed = new DOMParser().parseFromString(html, 'text/html');
201
+ const matches = safeQueryAll(parsed, selector);
202
+ const source = matches[targetIndex] || matches[0];
203
+
204
+ if (!source) return null;
205
+
206
+ return Array.from(source.children)
207
+ .filter((node) => GRAFT_ALLOWED_TAGS.has(node.tagName.toLowerCase()))
208
+ .map((node) => {
209
+ sanitizeGraft(node);
210
+ return node;
211
+ });
212
+ };
213
+
214
+ /**
215
+ * How many of the grafted cells are products.
216
+ *
217
+ * A grid cell is not always one: a merchandising tile is a first-class child
218
+ * of the grid and marks itself as such. Counting NODES put "showing 5 of 6"
219
+ * under four cards and let {to} run past {total} — precisely what the readout
220
+ * was built to stop doing. A list that emits no tiles counts every node, which
221
+ * is what it always did.
222
+ */
223
+ const countProducts = (nodes: Element[]): number =>
224
+ nodes.filter((node) => !node.hasAttribute('data-product-grid-insert')).length;
225
+
226
+ /**
227
+ * Next patches `history.replaceState`, so this also re-syncs `useSearchParams`
228
+ * for every block on the page without triggering a navigation.
229
+ */
230
+ const replaceListingUrl = (href: string) => {
231
+ try {
232
+ window.history.replaceState(null, '', href);
233
+ } catch {
234
+ // A blocked history call only costs the deep link, never the products.
235
+ }
236
+ };
237
+
238
+ const fillTemplate = (
239
+ template: string,
240
+ values: Record<string, string>
241
+ ): string =>
242
+ template.replace(
243
+ /\{(shown|total|from|to|page|pages)\}/g,
244
+ (match, key: string) => (key in values ? values[key] : match)
245
+ );
246
+
247
+ /**
248
+ * Functional listing block: pagination. Page counts are commerce-driven (the
249
+ * `pagination` payload of the category API); the editor owns the mode, how
250
+ * many page links are shown, which affordances appear and the labels.
251
+ *
252
+ * APPEND ('loadmore' + Append In Place, off by default). The first page is
253
+ * still whatever the server rendered — a crawler and a cold visit see products
254
+ * with no javascript at all, and the "load more" anchor is still a real link
255
+ * to page N+1, so the whole listing stays reachable. Append is layered on top
256
+ * of that: the click is intercepted only once a target list is actually found.
257
+ *
258
+ * The next page is taken from ITS OWN SERVER RESPONSE and its rows are moved
259
+ * into the live list, rather than fetching JSON and re-rendering cards here.
260
+ * That is the only way the appended cards can be *identical* to the first
261
+ * page's: they were produced by the same grid block, the same properties, the
262
+ * same money format and — the part a JSON path cannot reach at all — the same
263
+ * designer-authored row layout, iterator bindings and item scope. Grafting is
264
+ * kept strictly additive (nothing existing is touched) and every grafted node
265
+ * is tracked so a later navigation can take it back out; React never removes
266
+ * DOM it did not create, so leaving them would strand stale products in a
267
+ * freshly sorted list.
268
+ *
269
+ * Additive means visually additive too, not just "appended to the end of the
270
+ * children". A grid pins a merchandising tile by a cell rule, and page two's
271
+ * tiles carry page two's key, so no rule the live page wrote can move them on
272
+ * top of a card the shopper already saw. What comes back is markup off the
273
+ * wire either way, so it is scrubbed against an allowlist before it is cloned.
274
+ *
275
+ * URL: each appended page is written with `replaceState`, never `pushState`.
276
+ * pushState would stack one history entry per append, so "back" would walk the
277
+ * shopper through every batch instead of leaving the listing; replaceState
278
+ * keeps a single entry. A click inside the list re-writes it to the page the
279
+ * clicked row came from, so following a product and coming back lands on what
280
+ * the shopper was looking at rather than on page 1. The cost, deliberately
281
+ * accepted: the restored URL renders that one page rather than re-fetching the
282
+ * whole appended run.
283
+ *
284
+ * Parts: list, page, prev, next, first, last, ellipsis, load-more, status,
285
+ * end, error, retry.
286
+ * Properties: mode, maxLinks, prevLabel, nextLabel, loadMoreLabel,
287
+ * showPrevNext, showFirstLast, firstLabel, lastLabel, showEllipsis,
288
+ * ellipsisLabel, navLabel, queryParam, append, appendTrigger, appendTarget,
289
+ * prefetchOffset, loadingLabel, errorLabel, retryLabel, endLabel, showStatus,
290
+ * statusTemplate, statusPosition, groupThousands.
291
+ */
292
+ const ListingPaginationBlock = ({
293
+ block,
294
+ placeholderId,
295
+ sectionId,
296
+ selectedBlockId = null,
297
+ currentBreakpoint = 'desktop',
298
+ isDesigner = false
299
+ }: BlockRendererProps) => {
300
+ const listing = useListingContext();
301
+ const pathname = usePathname();
302
+ const searchParams = useSearchParams();
303
+ const { locale } = useLocalization();
304
+
305
+ const mode = String(
306
+ getResponsiveValue(block.properties?.mode, currentBreakpoint, 'pages')
307
+ );
308
+ const maxLinksRaw = Math.round(
309
+ Number(getResponsiveValue(block.properties?.maxLinks, currentBreakpoint, 5))
310
+ );
311
+ const maxLinks = Number.isFinite(maxLinksRaw)
312
+ ? Math.min(9, Math.max(3, maxLinksRaw))
313
+ : 5;
314
+ const prevLabel = String(
315
+ getResponsiveValue(block.properties?.prevLabel, currentBreakpoint, 'Previous')
316
+ );
317
+ const nextLabel = String(
318
+ getResponsiveValue(block.properties?.nextLabel, currentBreakpoint, 'Next')
319
+ );
320
+ const loadMoreLabel = String(
321
+ getResponsiveValue(
322
+ block.properties?.loadMoreLabel,
323
+ currentBreakpoint,
324
+ 'Load more'
325
+ )
326
+ );
327
+ const showPrevNext = readToggle(
328
+ block.properties?.showPrevNext,
329
+ currentBreakpoint,
330
+ true
331
+ );
332
+ const showFirstLast = readToggle(
333
+ block.properties?.showFirstLast,
334
+ currentBreakpoint,
335
+ false
336
+ );
337
+ const firstLabel = String(
338
+ getResponsiveValue(block.properties?.firstLabel, currentBreakpoint, 'First')
339
+ );
340
+ const lastLabel = String(
341
+ getResponsiveValue(block.properties?.lastLabel, currentBreakpoint, 'Last')
342
+ );
343
+ const showEllipsis = readToggle(
344
+ block.properties?.showEllipsis,
345
+ currentBreakpoint,
346
+ false
347
+ );
348
+ const ellipsisLabel = String(
349
+ getResponsiveValue(block.properties?.ellipsisLabel, currentBreakpoint, '…')
350
+ );
351
+ const navLabel = String(
352
+ getResponsiveValue(block.properties?.navLabel, currentBreakpoint, 'Pagination')
353
+ );
354
+ // Commerce normally paginates on `page`, but a brand running a custom
355
+ // listing route can move it; the filter blocks reset the same key.
356
+ const queryParam = String(
357
+ getResponsiveValue(block.properties?.queryParam, currentBreakpoint, 'page')
358
+ );
359
+
360
+ const append = readToggle(block.properties?.append, currentBreakpoint, false);
361
+ const appendTrigger = resolveOption<AppendTrigger>(
362
+ block.properties?.appendTrigger,
363
+ currentBreakpoint,
364
+ 'button',
365
+ APPEND_TRIGGERS
366
+ );
367
+ const appendTarget = String(
368
+ getResponsiveValue(
369
+ block.properties?.appendTarget,
370
+ currentBreakpoint,
371
+ '[data-product-grid]'
372
+ )
373
+ );
374
+ const prefetchOffsetRaw = Math.round(
375
+ Number(
376
+ getResponsiveValue(block.properties?.prefetchOffset, currentBreakpoint, 200)
377
+ )
378
+ );
379
+ const prefetchOffset = Number.isFinite(prefetchOffsetRaw)
380
+ ? Math.min(2000, Math.max(0, prefetchOffsetRaw))
381
+ : 200;
382
+ const loadingLabel = String(
383
+ getResponsiveValue(block.properties?.loadingLabel, currentBreakpoint, 'Loading…')
384
+ );
385
+ const errorLabel = String(
386
+ getResponsiveValue(
387
+ block.properties?.errorLabel,
388
+ currentBreakpoint,
389
+ 'Could not load more products.'
390
+ )
391
+ );
392
+ const retryLabel = String(
393
+ getResponsiveValue(block.properties?.retryLabel, currentBreakpoint, 'Try again')
394
+ );
395
+ const endLabel = String(
396
+ getResponsiveValue(block.properties?.endLabel, currentBreakpoint, '')
397
+ );
398
+ const showStatus = readToggle(
399
+ block.properties?.showStatus,
400
+ currentBreakpoint,
401
+ false
402
+ );
403
+ const statusTemplate = String(
404
+ getResponsiveValue(
405
+ block.properties?.statusTemplate,
406
+ currentBreakpoint,
407
+ 'Showing {shown} of {total}'
408
+ )
409
+ );
410
+ const statusPosition = resolveOption<StatusPosition>(
411
+ block.properties?.statusPosition,
412
+ currentBreakpoint,
413
+ 'before',
414
+ STATUS_POSITIONS
415
+ );
416
+ const groupThousands = readToggle(
417
+ block.properties?.groupThousands,
418
+ currentBreakpoint,
419
+ false
420
+ );
421
+
422
+ const navRef = React.useRef<HTMLElement | null>(null);
423
+ const sentinelRef = React.useRef<HTMLDivElement | null>(null);
424
+ /** Every node this block grafted, so a later navigation can take them back. */
425
+ const appendedNodesRef = React.useRef<Element[]>([]);
426
+ /** Synchronous, unlike state: the sentinel must not queue a second request. */
427
+ const inFlightRef = React.useRef(false);
428
+ const abortRef = React.useRef<AbortController | null>(null);
429
+ const [appendedPage, setAppendedPage] = React.useState(0);
430
+ const [appendedCount, setAppendedCount] = React.useState(0);
431
+ const [loadState, setLoadState] = React.useState<'idle' | 'loading' | 'error'>(
432
+ 'idle'
433
+ );
434
+
435
+ const currentPage = listing?.pagination.currentPage ?? 1;
436
+ const totalPages = listing?.pagination.totalPages ?? 1;
437
+ const pageSize = listing?.pagination.pageSize ?? 0;
438
+ const totalCount = listing?.pagination.totalCount ?? 0;
439
+ const pageProducts = listing?.products.length ?? 0;
440
+ const products = listing?.products;
441
+ // 0 means nothing has been appended, so the block is still on the page the
442
+ // server rendered.
443
+ const loadedPage = appendedPage > 0 ? appendedPage : currentPage;
444
+ const appendEnabled = append && mode === 'loadmore';
445
+
446
+ const hrefForPage = React.useCallback(
447
+ (page: number) => {
448
+ const params = new URLSearchParams(
449
+ searchParams ? searchParams.toString() : ''
450
+ );
451
+
452
+ if (page <= 1) {
453
+ params.delete(queryParam);
454
+ } else {
455
+ params.set(queryParam, String(page));
456
+ }
457
+
458
+ return buildListingHref(pathname || listing?.path || '', params);
459
+ },
460
+ [searchParams, queryParam, pathname, listing?.path]
461
+ );
462
+
463
+ const loadNextPage = React.useCallback(async () => {
464
+ // Fetching inside the canvas would drop real product markup into the page
465
+ // the author is composing, where it cannot be selected or styled.
466
+ if (isDesigner || inFlightRef.current) return;
467
+ if (loadedPage >= totalPages) return;
468
+
469
+ const target = findAppendTarget(navRef.current, appendTarget);
470
+ // No list to append into: the anchor is left to navigate instead.
471
+ if (!target) return;
472
+
473
+ const nextPage = loadedPage + 1;
474
+ const controller = new AbortController();
475
+
476
+ inFlightRef.current = true;
477
+ abortRef.current = controller;
478
+ setLoadState('loading');
479
+
480
+ try {
481
+ const response = await fetch(hrefForPage(nextPage), {
482
+ signal: controller.signal,
483
+ credentials: 'same-origin',
484
+ headers: { Accept: 'text/html' }
485
+ });
486
+
487
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
488
+
489
+ const nodes = extractAppendNodes(
490
+ await response.text(),
491
+ appendTarget,
492
+ indexOfTarget(target, appendTarget)
493
+ );
494
+
495
+ if (!nodes) throw new Error('No list in the response');
496
+
497
+ const fragment = document.createDocumentFragment();
498
+ nodes.forEach((node) => {
499
+ const clone = document.importNode(node, true);
500
+ // Which page a row came from, so a click on it can restore that page.
501
+ clone.setAttribute('data-listing-appended', String(nextPage));
502
+ appendedNodesRef.current.push(clone);
503
+ fragment.appendChild(clone);
504
+ });
505
+ target.appendChild(fragment);
506
+
507
+ setAppendedPage(nextPage);
508
+ setAppendedCount((count) => count + countProducts(nodes));
509
+ setLoadState('idle');
510
+ replaceListingUrl(hrefForPage(nextPage));
511
+ } catch {
512
+ // An abort is this block unmounting or a navigation, not a failure the
513
+ // shopper should be shown.
514
+ if (!controller.signal.aborted) setLoadState('error');
515
+ } finally {
516
+ inFlightRef.current = false;
517
+ if (abortRef.current === controller) abortRef.current = null;
518
+ }
519
+ }, [isDesigner, loadedPage, totalPages, appendTarget, hrefForPage]);
520
+
521
+ React.useEffect(() => {
522
+ // A sort, a filter or a page link re-renders the list from the server.
523
+ // React only owns the children it created, so the rows grafted from the
524
+ // previous URL would survive that render as stale products.
525
+ if (appendedNodesRef.current.length > 0) {
526
+ appendedNodesRef.current.forEach((node) => node.remove());
527
+ appendedNodesRef.current = [];
528
+ }
529
+
530
+ if (abortRef.current) abortRef.current.abort();
531
+ inFlightRef.current = false;
532
+ setAppendedPage(0);
533
+ setAppendedCount(0);
534
+ setLoadState('idle');
535
+ }, [products]);
536
+
537
+ React.useEffect(
538
+ () => () => {
539
+ if (abortRef.current) abortRef.current.abort();
540
+ appendedNodesRef.current.forEach((node) => node.remove());
541
+ appendedNodesRef.current = [];
542
+ },
543
+ []
544
+ );
545
+
546
+ React.useEffect(() => {
547
+ if (!appendEnabled || isDesigner) return undefined;
548
+ if (appendTrigger !== 'scroll') return undefined;
549
+ // The end of the list: an observer that stays armed here fires forever
550
+ // against a request that can never be made.
551
+ if (loadedPage >= totalPages) return undefined;
552
+ // 'loading' keeps a second request from being queued behind the first;
553
+ // 'error' keeps a failed page from being hammered on every scroll frame —
554
+ // the shopper re-arms it through the retry button.
555
+ if (loadState !== 'idle') return undefined;
556
+ if (typeof IntersectionObserver === 'undefined') return undefined;
557
+
558
+ const sentinel = sentinelRef.current;
559
+ if (!sentinel) return undefined;
560
+
561
+ const observer = new IntersectionObserver(
562
+ (entries) => {
563
+ if (!entries.some((entry) => entry.isIntersecting)) return;
564
+ if (inFlightRef.current) return;
565
+ // Re-armed by this effect once the page number moves; leaving it
566
+ // connected would re-fire while the request is still in flight.
567
+ observer.disconnect();
568
+ loadNextPage();
569
+ },
570
+ { rootMargin: `${prefetchOffset}px 0px` }
571
+ );
572
+
573
+ observer.observe(sentinel);
574
+ return () => observer.disconnect();
575
+ }, [
576
+ appendEnabled,
577
+ isDesigner,
578
+ appendTrigger,
579
+ loadedPage,
580
+ totalPages,
581
+ loadState,
582
+ prefetchOffset,
583
+ loadNextPage
584
+ ]);
585
+
586
+ React.useEffect(() => {
587
+ if (!appendEnabled || isDesigner) return undefined;
588
+
589
+ const target = findAppendTarget(navRef.current, appendTarget);
590
+ if (!target) return undefined;
591
+
592
+ // Capture phase, so the page is recorded before a client-side Link
593
+ // navigation takes over.
594
+ const handleClick = (event: Event) => {
595
+ if (appendedNodesRef.current.length === 0) return;
596
+
597
+ const origin = event.target as Element | null;
598
+ const link = origin && origin.closest ? origin.closest('a[href]') : null;
599
+ if (!link || !target.contains(link)) return;
600
+
601
+ const batch = link.closest('[data-listing-appended]');
602
+ const page = batch
603
+ ? Number(batch.getAttribute('data-listing-appended'))
604
+ : currentPage;
605
+
606
+ if (!Number.isFinite(page) || page < 1) return;
607
+ replaceListingUrl(hrefForPage(page));
608
+ };
609
+
610
+ target.addEventListener('click', handleClick, true);
611
+ return () => target.removeEventListener('click', handleClick, true);
612
+ }, [appendEnabled, isDesigner, appendTarget, hrefForPage, currentPage]);
613
+
614
+ // Navigating inside the designer iframe would drop the author out of the
615
+ // page they are editing, so links are inert there.
616
+ const guard = (event: React.MouseEvent) => {
617
+ if (isDesigner) event.preventDefault();
618
+ };
619
+
620
+ const handleLoadMore = (event: React.MouseEvent) => {
621
+ if (isDesigner) {
622
+ event.preventDefault();
623
+ return;
624
+ }
625
+
626
+ if (!appendEnabled || event.defaultPrevented) return;
627
+ // A modified click is the shopper asking for a new tab: the href is theirs.
628
+ if (
629
+ event.metaKey ||
630
+ event.ctrlKey ||
631
+ event.shiftKey ||
632
+ event.altKey ||
633
+ event.button !== 0
634
+ ) {
635
+ return;
636
+ }
637
+
638
+ // Only once there is somewhere to append; otherwise the link navigates,
639
+ // which is what it does with no javascript at all.
640
+ if (!findAppendTarget(navRef.current, appendTarget)) return;
641
+
642
+ event.preventDefault();
643
+ loadNextPage();
644
+ };
645
+
646
+ const formatCount = (value: number): string => {
647
+ if (!groupThousands) return String(value);
648
+
649
+ // Explicit locale: an implicit one resolves to the Node default on the
650
+ // server and the browser's on the client, so the grouped number would
651
+ // differ between SSR and hydration.
652
+ try {
653
+ return new Intl.NumberFormat(locale || 'tr-TR').format(value);
654
+ } catch {
655
+ return String(value);
656
+ }
657
+ };
658
+
659
+ // How many rows are on screen right now: what the server rendered for this
660
+ // page plus whatever has been appended to it. Counting what is there rather
661
+ // than multiplying the page size keeps the readout honest for a shopper who
662
+ // deep-linked into the middle of the listing.
663
+ const shownCount = pageProducts + appendedCount;
664
+ const firstIndex = pageSize > 0 ? (currentPage - 1) * pageSize + 1 : 1;
665
+ const statusText = fillTemplate(statusTemplate, {
666
+ shown: formatCount(shownCount),
667
+ total: formatCount(totalCount),
668
+ from: formatCount(shownCount > 0 ? firstIndex : 0),
669
+ to: formatCount(shownCount > 0 ? firstIndex + shownCount - 1 : 0),
670
+ page: formatCount(loadedPage),
671
+ pages: formatCount(totalPages)
672
+ });
673
+
674
+ const statusNode = showStatus ? (
675
+ <p
676
+ data-listing-pagination-status=""
677
+ data-listing-shown={shownCount}
678
+ data-listing-total={totalCount}
679
+ {...partAttrs('status', { empty: shownCount === 0 })}
680
+ // The count changes on append without any navigation, so a screen reader
681
+ // is told rather than left on the number it read at page load.
682
+ aria-live="polite"
683
+ style={{ margin: 0 }}
684
+ >
685
+ {statusText}
686
+ </p>
687
+ ) : null;
688
+
689
+ // With no status the block returns exactly the element it always has; the
690
+ // fragment is only introduced once a designer asks for the readout.
691
+ const withStatus = (node: React.ReactNode): React.ReactElement => {
692
+ if (!statusNode) return node as React.ReactElement;
693
+
694
+ return (
695
+ <>
696
+ {statusPosition === 'after' ? null : statusNode}
697
+ {node}
698
+ {statusPosition === 'after' ? statusNode : null}
699
+ </>
700
+ );
701
+ };
702
+
703
+ if (!listing || totalPages <= 1) {
704
+ if (listing && statusNode) return statusNode;
705
+
706
+ return isDesigner ? (
707
+ <div data-listing-pagination="" style={listingPlaceholderStyle}>
708
+ Pagination (single page)
709
+ </div>
710
+ ) : null;
711
+ }
712
+
713
+ if (mode === 'loadmore') {
714
+ if (loadedPage >= totalPages) {
715
+ const endNode =
716
+ appendEnabled && endLabel ? (
717
+ <p
718
+ data-listing-load-end=""
719
+ {...partAttrs('end')}
720
+ style={{ margin: 0 }}
721
+ >
722
+ {endLabel}
723
+ </p>
724
+ ) : null;
725
+
726
+ if (!statusNode && !endNode) {
727
+ return isDesigner ? (
728
+ <div data-listing-pagination="" style={listingPlaceholderStyle}>
729
+ Load more (last page)
730
+ </div>
731
+ ) : null;
732
+ }
733
+
734
+ return withStatus(endNode);
735
+ }
736
+
737
+ return withStatus(
738
+ <nav
739
+ ref={navRef}
740
+ data-listing-pagination=""
741
+ data-pagination-mode="loadmore"
742
+ {...partAttrs('list')}
743
+ >
744
+ <NextLink
745
+ href={hrefForPage(loadedPage + 1)}
746
+ rel="next"
747
+ onClick={handleLoadMore}
748
+ data-listing-load-more=""
749
+ {...partAttrs(
750
+ 'load-more',
751
+ appendEnabled ? { loading: loadState === 'loading' } : undefined
752
+ )}
753
+ style={linkStyle(false)}
754
+ aria-busy={
755
+ appendEnabled && loadState === 'loading' ? true : undefined
756
+ }
757
+ >
758
+ {appendEnabled && loadState === 'loading' ? loadingLabel : loadMoreLabel}
759
+ </NextLink>
760
+
761
+ {appendEnabled && loadState === 'error' && (
762
+ <>
763
+ <span data-listing-load-error="" {...partAttrs('error')} role="alert">
764
+ {errorLabel}
765
+ </span>
766
+ {/* A failed page is a dead end without this: the sentinel has
767
+ stood down and the anchor's own click is intercepted. */}
768
+ <button
769
+ type="button"
770
+ data-listing-load-retry=""
771
+ {...partAttrs('retry')}
772
+ onClick={() => loadNextPage()}
773
+ style={{ ...linkStyle(false), background: 'none', cursor: 'pointer' }}
774
+ >
775
+ {retryLabel}
776
+ </button>
777
+ </>
778
+ )}
779
+
780
+ {appendEnabled && appendTrigger === 'scroll' && (
781
+ <div
782
+ ref={sentinelRef}
783
+ data-listing-load-sentinel=""
784
+ aria-hidden="true"
785
+ style={{ width: '1px', height: '1px' }}
786
+ />
787
+ )}
788
+ </nav>
789
+ );
790
+ }
791
+
792
+ const half = Math.floor(maxLinks / 2);
793
+ const end = Math.min(totalPages, Math.max(currentPage + half, maxLinks));
794
+ const start = Math.max(1, Math.min(currentPage - half, end - maxLinks + 1));
795
+ const pages: number[] = [];
796
+ for (let page = start; page <= end; page += 1) {
797
+ pages.push(page);
798
+ }
799
+
800
+ // A designer who marked a child as the page-link layout gets that layout
801
+ // repeated per page; otherwise the built-in links render unchanged. Prev,
802
+ // next and the edge affordances stay built in either way.
803
+ const pageItems = pages.map((page) => ({
804
+ page,
805
+ name: String(page),
806
+ label: String(page),
807
+ url: hrefForPage(page),
808
+ current: page === currentPage
809
+ }));
810
+ const customPages = hasItemTemplate(block, currentBreakpoint) ? (
811
+ <ItemTemplateList
812
+ block={block}
813
+ items={pageItems as unknown as Record<string, unknown>[]}
814
+ placeholderId={placeholderId}
815
+ sectionId={sectionId}
816
+ isDesigner={isDesigner}
817
+ selectedBlockId={selectedBlockId}
818
+ currentBreakpoint={currentBreakpoint}
819
+ />
820
+ ) : null;
821
+
822
+ return withStatus(
823
+ <nav
824
+ data-listing-pagination=""
825
+ data-pagination-mode="pages"
826
+ aria-label={navLabel}
827
+ {...partAttrs('list')}
828
+ style={{
829
+ display: 'flex',
830
+ alignItems: 'center',
831
+ gap: 'var(--listing-pagination-gap, 8px)',
832
+ flexWrap: 'wrap'
833
+ }}
834
+ >
835
+ {showPrevNext && currentPage > 1 && (
836
+ <NextLink
837
+ href={hrefForPage(currentPage - 1)}
838
+ rel="prev"
839
+ onClick={guard}
840
+ data-pagination-prev=""
841
+ {...partAttrs('prev')}
842
+ style={linkStyle(false)}
843
+ >
844
+ {prevLabel}
845
+ </NextLink>
846
+ )}
847
+
848
+ {showFirstLast && start > 1 && (
849
+ <NextLink
850
+ href={hrefForPage(1)}
851
+ onClick={guard}
852
+ data-pagination-first=""
853
+ {...partAttrs('first')}
854
+ style={linkStyle(false)}
855
+ >
856
+ {firstLabel}
857
+ </NextLink>
858
+ )}
859
+
860
+ {showEllipsis && start > 1 && (
861
+ <span
862
+ data-pagination-ellipsis="start"
863
+ aria-hidden="true"
864
+ {...partAttrs('ellipsis')}
865
+ >
866
+ {ellipsisLabel}
867
+ </span>
868
+ )}
869
+
870
+ {customPages ??
871
+ pages.map((page) => (
872
+ <NextLink
873
+ key={page}
874
+ href={hrefForPage(page)}
875
+ onClick={guard}
876
+ data-pagination-page={page}
877
+ aria-current={page === currentPage ? 'page' : undefined}
878
+ {...partAttrs('page', { selected: page === currentPage })}
879
+ style={linkStyle(page === currentPage)}
880
+ >
881
+ {page}
882
+ </NextLink>
883
+ ))}
884
+
885
+ {showEllipsis && end < totalPages && (
886
+ <span
887
+ data-pagination-ellipsis="end"
888
+ aria-hidden="true"
889
+ {...partAttrs('ellipsis')}
890
+ >
891
+ {ellipsisLabel}
892
+ </span>
893
+ )}
894
+
895
+ {showFirstLast && end < totalPages && (
896
+ <NextLink
897
+ href={hrefForPage(totalPages)}
898
+ onClick={guard}
899
+ data-pagination-last=""
900
+ {...partAttrs('last')}
901
+ style={linkStyle(false)}
902
+ >
903
+ {lastLabel}
904
+ </NextLink>
905
+ )}
906
+
907
+ {showPrevNext && currentPage < totalPages && (
908
+ <NextLink
909
+ href={hrefForPage(currentPage + 1)}
910
+ rel="next"
911
+ onClick={guard}
912
+ data-pagination-next=""
913
+ {...partAttrs('next')}
914
+ style={linkStyle(false)}
915
+ >
916
+ {nextLabel}
917
+ </NextLink>
918
+ )}
919
+ </nav>
920
+ );
921
+ };
922
+
923
+ export default ListingPaginationBlock;