@akinon/pz-theme 2.0.37-rc.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 +7 -31
  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,1110 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+ import { useRouter } from 'next/navigation';
6
+ import { useSession } from 'next-auth/react';
7
+ import { useAddStockAlertMutation } from '@akinon/next/data/client/wishlist';
8
+ import { getResponsiveValue, partAttrs } from '../utils';
9
+ import { useOverlay } from '../hooks/use-overlay';
10
+ import { BlockRendererProps } from './block-renderer-registry';
11
+ import {
12
+ DesignerPlaceholder,
13
+ ThemeVariantGroup,
14
+ ThemeVariantOption,
15
+ getVariantOptionColor,
16
+ getVariantOptionImage,
17
+ getVariantOptionStock,
18
+ pickProductField,
19
+ readToggle,
20
+ useThemeProduct
21
+ } from './product-block-utils';
22
+
23
+ const getOptionUrl = (option: ThemeVariantOption): string =>
24
+ String(option?.product?.absolute_url || '').trim();
25
+
26
+ const isOptionDisabled = (option: ThemeVariantOption): boolean =>
27
+ option?.is_selectable === false || !getOptionUrl(option);
28
+
29
+ /**
30
+ * Sold out, as opposed to merely unpickable. `isOptionDisabled` also catches an
31
+ * option commerce sent without a url, and offering to tell a shopper when an
32
+ * IN-STOCK product "comes back" would be a lie. Commerce spells the same fact
33
+ * three ways across payloads, so all three are read.
34
+ */
35
+ const isOptionSoldOut = (option: ThemeVariantOption): boolean =>
36
+ option?.in_stock === false ||
37
+ option?.is_selectable === false ||
38
+ getVariantOptionStock(option) === 0;
39
+
40
+ /**
41
+ * The variant's own product id — the whole point of binding the alert to the
42
+ * option the shopper picked rather than to the product in general. Without one
43
+ * there is nothing to register, so the option stays the inert span it is today.
44
+ */
45
+ const getOptionProductPk = (option: ThemeVariantOption): number | null => {
46
+ const numeric = Number(option?.product?.pk);
47
+
48
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
49
+ };
50
+
51
+ const isEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
52
+
53
+ const SCRIPT_SCHEME = /^(?:javascript|vbscript|data):/;
54
+
55
+ /**
56
+ * Browsers strip whitespace and control characters while parsing a url, so a
57
+ * tab wedged into "javascript:" still resolves to a live handler; the merchant
58
+ * value is normalised the same way before it is judged.
59
+ */
60
+ const isScriptUrl = (value: string): boolean =>
61
+ SCRIPT_SCHEME.test(value.replace(/[\u0000-\u0020]/g, '').toLowerCase());
62
+
63
+ /** An authored url is merchant input on its way to `href`: never trusted. */
64
+ const safeHref = (value: string): string => {
65
+ const trimmed = value.trim();
66
+
67
+ return trimmed && !isScriptUrl(trimmed) ? trimmed : '';
68
+ };
69
+
70
+ /**
71
+ * Only the tokens this block supplies are substituted; anything else the author
72
+ * typed stays literal, so a stray brace in legal copy is never eaten.
73
+ */
74
+ const fillTemplate = (
75
+ template: string,
76
+ tokens: Record<string, string>
77
+ ): string =>
78
+ template.replace(/\{(option|attribute|product)\}/g, (match, key: string) =>
79
+ tokens[key] === undefined ? match : tokens[key]
80
+ );
81
+
82
+ /** Which sold-out option currently owns the alert form. */
83
+ type OpenAlert = {
84
+ /** Unique across groups, so two attributes cannot open one another's form. */
85
+ key: string;
86
+ /** Which group draws the inline panel — kept apart from `key` so a group
87
+ * whose attribute key contains the separator cannot be mismatched. */
88
+ groupKey: string;
89
+ productPk: number;
90
+ optionLabel: string;
91
+ attributeName: string;
92
+ };
93
+
94
+ /**
95
+ * The four shapes one picker can draw, as a designer names them per attribute.
96
+ * `box` is what the block has always drawn for its block-level `swatch` layout,
97
+ * whose stored value stays as it is so published compositions keep resolving to
98
+ * the same renderer path. `chip` is the round colour/photo swatch — it is NOT
99
+ * called `swatch` here precisely because that word already means the bordered
100
+ * box one control higher up the same panel.
101
+ */
102
+ const GROUP_LAYOUTS = ['box', 'chip', 'dropdown', 'text'];
103
+ const SWATCH_CONTENTS = ['auto', 'color', 'image'];
104
+ const DISABLED_STYLES = ['line-through', 'diagonal', 'none'];
105
+
106
+ /**
107
+ * A design almost never renders every attribute the same way: colour is a
108
+ * circle, size is a box, and a third attribute may be a dropdown. The panel has
109
+ * no map control, so the override is authored the same way attribute filters
110
+ * already are — `color:chip, size:box`, keyed by the commerce attribute key.
111
+ * Anything unparseable is skipped rather than guessed at, which is what keeps
112
+ * an empty or half-typed value on the block-wide layout.
113
+ */
114
+ const parseGroupLayouts = (raw: unknown): Record<string, string> => {
115
+ const map: Record<string, string> = {};
116
+
117
+ String(raw || '')
118
+ .split(',')
119
+ .forEach((entry) => {
120
+ const separator = entry.indexOf(':');
121
+ if (separator === -1) return;
122
+
123
+ const key = entry.slice(0, separator).trim().toLowerCase();
124
+ const value = entry.slice(separator + 1).trim().toLowerCase();
125
+
126
+ if (key && GROUP_LAYOUTS.includes(value)) map[key] = value;
127
+ });
128
+
129
+ return map;
130
+ };
131
+
132
+ /**
133
+ * Functional PDP block: renders the commerce variant matrix
134
+ * (product.variants). Each option is its own product URL, so selecting a
135
+ * variant is a navigation — that keeps SSR, canonical URLs and analytics
136
+ * intact. The editor owns layout, labels and styling.
137
+ *
138
+ * A sold-out option is a dead end by default and stays one unless a brand turns
139
+ * `stockAlert` on: then it becomes a real control that opens a back-in-stock
140
+ * form bound to THAT option's own product id.
141
+ *
142
+ * Parts: group, label, selected-value, dropdown, option-list, option, swatch,
143
+ * strike, low-stock, alert-backdrop, alert-panel, alert-title,
144
+ * alert-description, alert-close, alert-form, alert-label, alert-input,
145
+ * alert-consent, alert-submit, alert-success, alert-error, alert-signin.
146
+ * Properties: layout, groupLayouts, showGroupLabel, showSelectedValue,
147
+ * selectedValueSeparator, swatchContent, swatchSize, disabledStyle,
148
+ * strikeWidth, lowStockThreshold, lowStockLabel, gap, optionGap, stockAlert,
149
+ * stockAlertPresentation, stockAlertTriggerLabel, stockAlertTitle,
150
+ * stockAlertDescription, stockAlertEmailLabel, stockAlertEmailPlaceholder,
151
+ * stockAlertConsentText, stockAlertConsentRequired, stockAlertSubmitLabel,
152
+ * stockAlertLoadingLabel, stockAlertSuccessLabel, stockAlertErrorLabel,
153
+ * stockAlertInvalidEmailLabel, stockAlertConsentErrorLabel,
154
+ * stockAlertSignedOutText, stockAlertSignInLabel, stockAlertSignInUrl,
155
+ * stockAlertCloseLabel.
156
+ */
157
+ const VariantSelectorBlock = ({
158
+ block,
159
+ currentBreakpoint = 'desktop',
160
+ isDesigner = false
161
+ }: BlockRendererProps) => {
162
+ const product = useThemeProduct();
163
+ const router = useRouter();
164
+ const { data: session, status: sessionStatus } = useSession();
165
+ const [addStockAlert, { isLoading: isAlertSubmitting }] =
166
+ useAddStockAlertMutation();
167
+
168
+ const [openAlert, setOpenAlert] = React.useState<OpenAlert | null>(null);
169
+ const [alertEmail, setAlertEmail] = React.useState('');
170
+ const [alertConsent, setAlertConsent] = React.useState(false);
171
+ const [alertStatus, setAlertStatus] = React.useState<
172
+ 'idle' | 'success' | 'error'
173
+ >('idle');
174
+ const [alertErrorKind, setAlertErrorKind] = React.useState<
175
+ 'email' | 'consent' | 'request'
176
+ >('request');
177
+ const alertTriggerRef = React.useRef<HTMLElement | null>(null);
178
+ // React's own id rather than the block id: the field id is markup, and two
179
+ // copies of this block on one page must not both answer to the same label.
180
+ const alertFieldId = React.useId();
181
+
182
+ const layoutValue = String(
183
+ getResponsiveValue(block.properties?.layout, currentBreakpoint, 'swatch')
184
+ );
185
+ const layout = layoutValue === 'dropdown' ? 'dropdown' : 'swatch';
186
+ const showGroupLabel = readToggle(
187
+ block.properties?.showGroupLabel,
188
+ currentBreakpoint,
189
+ true
190
+ );
191
+ const gap = Number(
192
+ getResponsiveValue(block.properties?.gap, currentBreakpoint, 16)
193
+ );
194
+ const optionGap = Number(
195
+ getResponsiveValue(block.properties?.optionGap, currentBreakpoint, 8)
196
+ );
197
+
198
+ const groupLayouts = parseGroupLayouts(
199
+ getResponsiveValue(block.properties?.groupLayouts, currentBreakpoint, '')
200
+ );
201
+ const swatchContentValue = String(
202
+ getResponsiveValue(block.properties?.swatchContent, currentBreakpoint, 'auto')
203
+ );
204
+ const swatchContent = SWATCH_CONTENTS.includes(swatchContentValue)
205
+ ? swatchContentValue
206
+ : 'auto';
207
+ const swatchSizeValue = Number(
208
+ getResponsiveValue(block.properties?.swatchSize, currentBreakpoint, 32)
209
+ );
210
+ const swatchSize =
211
+ Number.isFinite(swatchSizeValue) && swatchSizeValue > 0
212
+ ? swatchSizeValue
213
+ : 32;
214
+ const showSelectedValue = readToggle(
215
+ block.properties?.showSelectedValue,
216
+ currentBreakpoint,
217
+ false
218
+ );
219
+ const selectedValueSeparator = String(
220
+ getResponsiveValue(
221
+ block.properties?.selectedValueSeparator,
222
+ currentBreakpoint,
223
+ ': '
224
+ )
225
+ );
226
+ const disabledStyleValue = String(
227
+ getResponsiveValue(
228
+ block.properties?.disabledStyle,
229
+ currentBreakpoint,
230
+ 'line-through'
231
+ )
232
+ );
233
+ const disabledStyle = DISABLED_STYLES.includes(disabledStyleValue)
234
+ ? disabledStyleValue
235
+ : 'line-through';
236
+ const strikeWidthValue = Number(
237
+ getResponsiveValue(block.properties?.strikeWidth, currentBreakpoint, 1)
238
+ );
239
+ const strikeWidth =
240
+ Number.isFinite(strikeWidthValue) && strikeWidthValue > 0
241
+ ? strikeWidthValue
242
+ : 1;
243
+ const lowStockThresholdValue = Number(
244
+ getResponsiveValue(block.properties?.lowStockThreshold, currentBreakpoint, 0)
245
+ );
246
+ // 0 is off: a marker may only appear on a threshold a brand asked for.
247
+ const lowStockThreshold =
248
+ Number.isFinite(lowStockThresholdValue) && lowStockThresholdValue > 0
249
+ ? Math.round(lowStockThresholdValue)
250
+ : 0;
251
+ const lowStockLabel = String(
252
+ getResponsiveValue(
253
+ block.properties?.lowStockLabel,
254
+ currentBreakpoint,
255
+ 'Low stock'
256
+ )
257
+ );
258
+
259
+ /**
260
+ * The whole back-in-stock capability behind one switch, off by default: a
261
+ * composition nobody edited must keep rendering the inert struck-through
262
+ * span it renders today, byte for byte.
263
+ */
264
+ const stockAlertEnabled = readToggle(
265
+ block.properties?.stockAlert,
266
+ currentBreakpoint,
267
+ false
268
+ );
269
+
270
+ const alertProp = (key: string, fallback: string): string => {
271
+ const value = getResponsiveValue(
272
+ block.properties?.[key],
273
+ currentBreakpoint,
274
+ fallback
275
+ );
276
+
277
+ return value === undefined || value === null ? fallback : String(value);
278
+ };
279
+
280
+ // Every word a shopper reads is the brand's, never ours: these markets
281
+ // attach legal text to collecting an email and only the brand knows it.
282
+ const alertPresentation =
283
+ alertProp('stockAlertPresentation', 'inline') === 'modal'
284
+ ? 'modal'
285
+ : 'inline';
286
+ const alertTriggerLabel = alertProp(
287
+ 'stockAlertTriggerLabel',
288
+ 'Notify me when back in stock'
289
+ );
290
+ const alertTitle = alertProp('stockAlertTitle', 'Notify me when available');
291
+ const alertDescription = alertProp(
292
+ 'stockAlertDescription',
293
+ 'We will email you when {attribute} {option} is back in stock.'
294
+ );
295
+ const alertEmailLabel = alertProp('stockAlertEmailLabel', 'Email address');
296
+ const alertEmailPlaceholder = alertProp('stockAlertEmailPlaceholder', '');
297
+ const alertConsentText = alertProp('stockAlertConsentText', '');
298
+ const alertConsentRequired =
299
+ readToggle(
300
+ block.properties?.stockAlertConsentRequired,
301
+ currentBreakpoint,
302
+ false
303
+ ) && alertConsentText !== '';
304
+ const alertSubmitLabel = alertProp('stockAlertSubmitLabel', 'Notify me');
305
+ const alertLoadingLabel = alertProp('stockAlertLoadingLabel', '');
306
+ const alertSuccessLabel = alertProp(
307
+ 'stockAlertSuccessLabel',
308
+ 'Done — we will email you when it is back.'
309
+ );
310
+ const alertErrorLabel = alertProp(
311
+ 'stockAlertErrorLabel',
312
+ 'We could not register your alert. Please try again.'
313
+ );
314
+ const alertInvalidEmailLabel = alertProp(
315
+ 'stockAlertInvalidEmailLabel',
316
+ 'Enter a valid email address.'
317
+ );
318
+ const alertConsentErrorLabel = alertProp(
319
+ 'stockAlertConsentErrorLabel',
320
+ 'Please accept to continue.'
321
+ );
322
+ const alertSignedOutText = alertProp(
323
+ 'stockAlertSignedOutText',
324
+ 'Sign in to be notified when this size is back in stock.'
325
+ );
326
+ const alertSignInLabel = alertProp('stockAlertSignInLabel', 'Sign in');
327
+ const alertSignInUrl = safeHref(alertProp('stockAlertSignInUrl', ''));
328
+ const alertCloseLabel = alertProp('stockAlertCloseLabel', 'Close');
329
+
330
+ /**
331
+ * Commerce registers a product alert against the SIGNED-IN account:
332
+ * `/wishlists/product-alerts/` answers 401 to a guest, which is why every
333
+ * brand that ships this sends the session's own address and sends a signed-out
334
+ * shopper to auth first. So a guest is told the truth and offered the way
335
+ * forward the brand authored — never a form that looks like it worked.
336
+ */
337
+ const alertSessionEmail = String(
338
+ (session as { user?: { email?: string } } | null)?.user?.email || ''
339
+ );
340
+ // The designer is never signed in as a shopper, and an author who could only
341
+ // ever see the signed-out branch could not style the form at all.
342
+ const alertSignedOut = !isDesigner && sessionStatus !== 'authenticated';
343
+
344
+ const closeAlert = () => {
345
+ setOpenAlert(null);
346
+ setAlertStatus('idle');
347
+ };
348
+
349
+ const openAlertFor = (entry: OpenAlert, trigger: HTMLElement | null) => {
350
+ alertTriggerRef.current = trigger;
351
+ setOpenAlert(entry);
352
+ // A fresh option is a fresh form: a success or an error left over from the
353
+ // last size must never read as this one's answer.
354
+ setAlertStatus('idle');
355
+ setAlertErrorKind('request');
356
+ setAlertConsent(false);
357
+ setAlertEmail(alertSessionEmail);
358
+ };
359
+
360
+ const handleAlertSubmit = async (event: React.FormEvent) => {
361
+ event.preventDefault();
362
+
363
+ // A submit inside the designer must never reach the brand's alert list.
364
+ if (isDesigner || isAlertSubmitting || !openAlert) return;
365
+
366
+ if (alertConsentRequired && !alertConsent) {
367
+ setAlertErrorKind('consent');
368
+ setAlertStatus('error');
369
+ return;
370
+ }
371
+
372
+ const value = alertEmail.trim();
373
+ if (!isEmail(value)) {
374
+ setAlertErrorKind('email');
375
+ setAlertStatus('error');
376
+ return;
377
+ }
378
+
379
+ try {
380
+ await addStockAlert({
381
+ productPk: openAlert.productPk,
382
+ email: value
383
+ }).unwrap();
384
+ setAlertStatus('success');
385
+ } catch {
386
+ // Say so. A shopper who believes they are on a waiting list and is not
387
+ // is worse off than one who was told nothing.
388
+ setAlertErrorKind('request');
389
+ setAlertStatus('error');
390
+ }
391
+ };
392
+
393
+ /**
394
+ * Escape, the scroll lock, the focus trap and the `aria-modal` claim all
395
+ * come from the shared overlay primitive rather than from a copy kept here.
396
+ * This block can legitimately appear twice on one PDP — a desktop picker and
397
+ * a mobile one — and two hand-rolled traps, each watching the whole document
398
+ * and each reading the other's panel as "outside", pulled focus back and
399
+ * forth until the tab died. The primitive keeps one stack: only the overlay
400
+ * on top traps focus, answers Escape and claims the page is inert, and the
401
+ * scroll lock is counted once for the whole package.
402
+ */
403
+ const overlay = useOverlay<HTMLDivElement>({
404
+ open: stockAlertEnabled && openAlert !== null,
405
+ onClose: closeAlert,
406
+ isDesigner,
407
+ // The inline panel sits next in reading order behind no backdrop, so it
408
+ // neither traps focus nor claims anything about the page around it.
409
+ modal: alertPresentation === 'modal',
410
+ // No `fallbackFocusRef`: nothing this block draws outside the panel takes
411
+ // focus without a tabindex it does not have today, and a fallback that
412
+ // cannot hold focus would only hide the handover failing.
413
+ returnFocusRef: alertTriggerRef
414
+ });
415
+
416
+ const rawGroups = pickProductField<ThemeVariantGroup[]>(product, 'variants');
417
+ const groups = (Array.isArray(rawGroups) ? rawGroups : []).filter(
418
+ (group) => Array.isArray(group?.options) && group.options.length > 0
419
+ );
420
+
421
+ if (groups.length === 0) {
422
+ return isDesigner ? (
423
+ <DesignerPlaceholder
424
+ label="Variant selector (product has no variants)"
425
+ attribute="data-variant-selector"
426
+ />
427
+ ) : null;
428
+ }
429
+
430
+ const safeGap = Number.isFinite(gap) && gap >= 0 ? gap : 16;
431
+ const safeOptionGap =
432
+ Number.isFinite(optionGap) && optionGap >= 0 ? optionGap : 8;
433
+
434
+ const alertTokens = openAlert
435
+ ? {
436
+ option: openAlert.optionLabel,
437
+ attribute: openAlert.attributeName,
438
+ product: String(pickProductField<string>(product, 'title') || '')
439
+ }
440
+ : { option: '', attribute: '', product: '' };
441
+
442
+ const alertSucceeded = alertStatus === 'success';
443
+ const alertErrorMessage =
444
+ alertErrorKind === 'email'
445
+ ? alertInvalidEmailLabel
446
+ : alertErrorKind === 'consent'
447
+ ? alertConsentErrorLabel
448
+ : alertErrorLabel;
449
+
450
+ /**
451
+ * The form the shopper meets, bound to the one option they picked. Rendered
452
+ * inside that option's group for the inline presentation and once at the
453
+ * root for the modal, so it is never detached from what it is about.
454
+ */
455
+ const renderAlertBody = () => (
456
+ <>
457
+ {alertTitle && (
458
+ <strong
459
+ data-variant-alert-title=""
460
+ {...partAttrs('alert-title')}
461
+ style={{ display: 'block' }}
462
+ >
463
+ {fillTemplate(alertTitle, alertTokens)}
464
+ </strong>
465
+ )}
466
+
467
+ {alertDescription && (
468
+ <span
469
+ data-variant-alert-description=""
470
+ {...partAttrs('alert-description')}
471
+ style={{ display: 'block', fontSize: '0.9em' }}
472
+ >
473
+ {fillTemplate(alertDescription, alertTokens)}
474
+ </span>
475
+ )}
476
+
477
+ {alertSignedOut ? (
478
+ <>
479
+ <span
480
+ data-variant-alert-signed-out=""
481
+ {...partAttrs('alert-description')}
482
+ style={{ display: 'block', fontSize: '0.9em' }}
483
+ >
484
+ {fillTemplate(alertSignedOutText, alertTokens)}
485
+ </span>
486
+ {alertSignInUrl && (
487
+ <NextLink
488
+ href={alertSignInUrl}
489
+ data-variant-alert-signin=""
490
+ {...partAttrs('alert-signin')}
491
+ onClick={(event) => {
492
+ if (isDesigner) event.preventDefault();
493
+ }}
494
+ style={{ color: 'inherit' }}
495
+ >
496
+ {alertSignInLabel}
497
+ </NextLink>
498
+ )}
499
+ </>
500
+ ) : alertSucceeded ? (
501
+ <span
502
+ data-variant-alert-success=""
503
+ {...partAttrs('alert-success')}
504
+ role="status"
505
+ style={{ display: 'block' }}
506
+ >
507
+ {fillTemplate(alertSuccessLabel, alertTokens)}
508
+ </span>
509
+ ) : (
510
+ <form
511
+ data-variant-alert-form=""
512
+ {...partAttrs('alert-form', {
513
+ loading: isAlertSubmitting,
514
+ invalid: alertStatus === 'error'
515
+ })}
516
+ onSubmit={handleAlertSubmit}
517
+ noValidate
518
+ style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}
519
+ >
520
+ {alertEmailLabel && (
521
+ <label
522
+ data-variant-alert-label=""
523
+ {...partAttrs('alert-label')}
524
+ htmlFor={alertFieldId}
525
+ style={{ fontSize: '0.85em' }}
526
+ >
527
+ {alertEmailLabel}
528
+ </label>
529
+ )}
530
+
531
+ <input
532
+ id={alertFieldId}
533
+ type="email"
534
+ value={alertEmail}
535
+ onChange={(event) => {
536
+ setAlertEmail(event.target.value);
537
+ if (alertStatus === 'error') setAlertStatus('idle');
538
+ }}
539
+ placeholder={alertEmailPlaceholder}
540
+ aria-label={alertEmailLabel || alertTriggerLabel}
541
+ aria-invalid={alertStatus === 'error' && alertErrorKind === 'email'}
542
+ data-variant-alert-input=""
543
+ {...partAttrs('alert-input', {
544
+ invalid: alertStatus === 'error' && alertErrorKind === 'email'
545
+ })}
546
+ style={{
547
+ border: '1px solid currentColor',
548
+ borderRadius: 'inherit',
549
+ background: 'transparent',
550
+ color: 'inherit',
551
+ font: 'inherit',
552
+ padding: '10px 12px',
553
+ outline: 'none'
554
+ }}
555
+ />
556
+
557
+ {alertConsentText && (
558
+ <label
559
+ data-variant-alert-consent=""
560
+ {...partAttrs('alert-consent', {
561
+ invalid: alertStatus === 'error' && alertErrorKind === 'consent'
562
+ })}
563
+ style={{
564
+ display: 'flex',
565
+ alignItems: 'flex-start',
566
+ gap: '8px',
567
+ fontSize: '0.8em'
568
+ }}
569
+ >
570
+ {alertConsentRequired && (
571
+ <input
572
+ type="checkbox"
573
+ checked={alertConsent}
574
+ onChange={(event) => {
575
+ setAlertConsent(event.target.checked);
576
+ if (alertStatus === 'error') setAlertStatus('idle');
577
+ }}
578
+ aria-invalid={
579
+ alertStatus === 'error' && alertErrorKind === 'consent'
580
+ }
581
+ />
582
+ )}
583
+ <span>{fillTemplate(alertConsentText, alertTokens)}</span>
584
+ </label>
585
+ )}
586
+
587
+ <button
588
+ type="submit"
589
+ data-variant-alert-submit=""
590
+ {...partAttrs('alert-submit', {
591
+ loading: isAlertSubmitting,
592
+ disabled: isAlertSubmitting
593
+ })}
594
+ disabled={isAlertSubmitting}
595
+ aria-busy={isAlertSubmitting}
596
+ style={{
597
+ border: '1px solid currentColor',
598
+ borderRadius: 'inherit',
599
+ background: 'transparent',
600
+ color: 'inherit',
601
+ font: 'inherit',
602
+ padding: '10px 16px',
603
+ cursor: isAlertSubmitting ? 'not-allowed' : 'pointer',
604
+ opacity: isAlertSubmitting ? 0.7 : 1
605
+ }}
606
+ >
607
+ {isAlertSubmitting && alertLoadingLabel
608
+ ? alertLoadingLabel
609
+ : alertSubmitLabel}
610
+ </button>
611
+
612
+ {alertStatus === 'error' && (
613
+ <span
614
+ data-variant-alert-error=""
615
+ {...partAttrs('alert-error', { invalid: true })}
616
+ role="alert"
617
+ style={{ fontSize: '0.85em' }}
618
+ >
619
+ {alertErrorMessage}
620
+ </span>
621
+ )}
622
+ </form>
623
+ )}
624
+ </>
625
+ );
626
+
627
+ const renderAlertPanel = (asModal: boolean) => (
628
+ <div
629
+ ref={overlay.panelRef}
630
+ data-variant-alert=""
631
+ data-variant-alert-state={
632
+ alertSignedOut
633
+ ? 'signed-out'
634
+ : isAlertSubmitting
635
+ ? 'busy'
636
+ : alertStatus
637
+ }
638
+ {...partAttrs('alert-panel')}
639
+ role={asModal ? 'dialog' : 'group'}
640
+ // Only where the backdrop really covers the page and the trap really
641
+ // holds: the inline panel, the canvas and a dialog that another dialog
642
+ // has since opened on top of all claim nothing.
643
+ aria-modal={overlay.ariaModal}
644
+ aria-label={fillTemplate(alertTitle || alertTriggerLabel, alertTokens)}
645
+ tabIndex={asModal ? -1 : undefined}
646
+ style={{
647
+ display: 'flex',
648
+ flexDirection: 'column',
649
+ gap: '8px',
650
+ ...(asModal
651
+ ? {
652
+ // Centred on its own rather than inside a flex wrapper, so the
653
+ // backdrop can stay a sibling and nothing extra sits between
654
+ // the dialog and the block root.
655
+ position: 'fixed',
656
+ top: '50%',
657
+ left: '50%',
658
+ transform: 'translate(-50%, -50%)',
659
+ zIndex: 1001,
660
+ width: 'calc(100% - 32px)',
661
+ maxWidth: '420px',
662
+ maxHeight: '90vh',
663
+ overflowY: 'auto',
664
+ padding: '20px',
665
+ background: 'var(--variant-alert-background, #ffffff)',
666
+ color: 'inherit'
667
+ }
668
+ : { marginTop: '12px' })
669
+ }}
670
+ >
671
+ {renderAlertBody()}
672
+
673
+ <button
674
+ type="button"
675
+ data-variant-alert-close=""
676
+ {...partAttrs('alert-close')}
677
+ onClick={closeAlert}
678
+ style={{
679
+ border: 0,
680
+ background: 'transparent',
681
+ color: 'inherit',
682
+ font: 'inherit',
683
+ padding: 0,
684
+ cursor: 'pointer',
685
+ textAlign: 'left'
686
+ }}
687
+ >
688
+ {alertCloseLabel}
689
+ </button>
690
+ </div>
691
+ );
692
+
693
+ return (
694
+ <div
695
+ data-variant-selector=""
696
+ data-variant-layout={layout}
697
+ style={{
698
+ display: 'flex',
699
+ flexDirection: 'column',
700
+ gap: `${safeGap}px`,
701
+ width: '100%'
702
+ }}
703
+ >
704
+ {groups.map((group, groupIndex) => {
705
+ const options = group.options || [];
706
+ const selectedOption = options.find(
707
+ (option) => option?.is_selected === true
708
+ );
709
+ // The <select> is controlled by URL, so an empty value needs a real
710
+ // matching option or React drops the selection on every render.
711
+ const selectedUrl = selectedOption ? getOptionUrl(selectedOption) : '';
712
+ const groupKey = String(group.attribute_key || groupIndex);
713
+ const attributeKey = String(group.attribute_key || '')
714
+ .trim()
715
+ .toLowerCase();
716
+ const override = attributeKey ? groupLayouts[attributeKey] : undefined;
717
+ const groupLayout =
718
+ override || (layout === 'dropdown' ? 'dropdown' : 'box');
719
+ const selectedLabel = String(
720
+ selectedOption?.label || selectedOption?.value || ''
721
+ );
722
+ const selectedValueNode =
723
+ showSelectedValue && selectedLabel ? (
724
+ <span
725
+ data-variant-selected-value=""
726
+ {...partAttrs('selected-value')}
727
+ style={{ opacity: 1 }}
728
+ >
729
+ {selectedLabel}
730
+ </span>
731
+ ) : null;
732
+
733
+ const renderOption = (
734
+ option: ThemeVariantOption,
735
+ optionIndex: number
736
+ ) => {
737
+ const label = String(option?.label || option?.value || '');
738
+ const url = getOptionUrl(option);
739
+ const disabled = isOptionDisabled(option);
740
+ const selected = option?.is_selected === true;
741
+ const struck = disabled && disabledStyle === 'line-through';
742
+ const showStrike = disabled && disabledStyle === 'diagonal';
743
+ // A number commerce actually sent, above zero (zero is sold out, not
744
+ // low) and at or under the threshold the brand set.
745
+ const stock =
746
+ lowStockThreshold > 0 ? getVariantOptionStock(option) : null;
747
+ const isLowStock =
748
+ stock !== null && stock > 0 && stock <= lowStockThreshold;
749
+ // Overlays are absolutely placed, so the option only becomes a
750
+ // positioning context when one of them is actually drawn.
751
+ const anchored = showStrike || isLowStock;
752
+
753
+ const color =
754
+ swatchContent === 'image' ? '' : getVariantOptionColor(option);
755
+ const image =
756
+ swatchContent === 'color' ? '' : getVariantOptionImage(option);
757
+ const swatchNode =
758
+ groupLayout !== 'chip'
759
+ ? null
760
+ : color ? (
761
+ <span
762
+ {...partAttrs('swatch')}
763
+ style={{
764
+ display: 'block',
765
+ width: `${swatchSize}px`,
766
+ height: `${swatchSize}px`,
767
+ background: color
768
+ }}
769
+ />
770
+ ) : image ? (
771
+ // Plain img: variant swatches come from arbitrary commerce
772
+ // CDNs that the brand next.config may not allow.
773
+ // eslint-disable-next-line @next/next/no-img-element
774
+ <img
775
+ src={image}
776
+ alt=""
777
+ loading="lazy"
778
+ {...partAttrs('swatch')}
779
+ style={{
780
+ display: 'block',
781
+ width: `${swatchSize}px`,
782
+ height: `${swatchSize}px`,
783
+ objectFit: 'cover'
784
+ }}
785
+ />
786
+ ) : null;
787
+
788
+ const optionStyle: React.CSSProperties =
789
+ groupLayout === 'text'
790
+ ? {
791
+ display: 'inline-flex',
792
+ alignItems: 'center',
793
+ color: 'inherit',
794
+ textDecoration: struck
795
+ ? 'line-through'
796
+ : selected
797
+ ? 'underline'
798
+ : 'none',
799
+ textUnderlineOffset: '3px',
800
+ opacity: disabled ? 0.4 : 1,
801
+ cursor: disabled ? 'not-allowed' : 'pointer',
802
+ ...(anchored ? { position: 'relative' } : null)
803
+ }
804
+ : groupLayout === 'chip'
805
+ ? {
806
+ display: 'inline-flex',
807
+ alignItems: 'center',
808
+ justifyContent: 'center',
809
+ padding: '2px',
810
+ color: 'inherit',
811
+ textDecoration: struck ? 'line-through' : 'none',
812
+ opacity: disabled ? 0.4 : 1,
813
+ cursor: disabled ? 'not-allowed' : 'pointer',
814
+ border: selected
815
+ ? '1px solid var(--variant-option-active, currentColor)'
816
+ : '1px solid var(--variant-option-border, transparent)',
817
+ ...(anchored ? { position: 'relative' } : null)
818
+ }
819
+ : {
820
+ display: 'inline-flex',
821
+ alignItems: 'center',
822
+ justifyContent: 'center',
823
+ minWidth: '40px',
824
+ padding: '8px 12px',
825
+ color: 'inherit',
826
+ textDecoration: struck ? 'line-through' : 'none',
827
+ opacity: disabled ? 0.4 : 1,
828
+ cursor: disabled ? 'not-allowed' : 'pointer',
829
+ border: selected
830
+ ? '1px solid var(--variant-option-active, currentColor)'
831
+ : '1px solid var(--variant-option-border, #d1d5db)',
832
+ ...(anchored ? { position: 'relative' } : null)
833
+ };
834
+
835
+ const body = swatchNode ?? label;
836
+ // Kept as a single child in the default case: an extra fragment with
837
+ // adjacent text children makes React's SSR emit comment separators,
838
+ // which would change the markup of a composition nobody edited.
839
+ const content = anchored ? (
840
+ <>
841
+ {body}
842
+ {showStrike && (
843
+ <svg
844
+ {...partAttrs('strike')}
845
+ aria-hidden="true"
846
+ viewBox="0 0 100 100"
847
+ preserveAspectRatio="none"
848
+ style={{
849
+ position: 'absolute',
850
+ left: 0,
851
+ top: 0,
852
+ width: '100%',
853
+ height: '100%',
854
+ color: 'inherit',
855
+ pointerEvents: 'none'
856
+ }}
857
+ >
858
+ {/* Stretched to the box, so the line always runs corner to
859
+ corner; the stroke stays the authored width. */}
860
+ <line
861
+ x1="0"
862
+ y1="100"
863
+ x2="100"
864
+ y2="0"
865
+ stroke="currentColor"
866
+ strokeWidth={strikeWidth}
867
+ vectorEffect="non-scaling-stroke"
868
+ />
869
+ </svg>
870
+ )}
871
+ {isLowStock && (
872
+ <span
873
+ {...partAttrs('low-stock')}
874
+ role="img"
875
+ aria-label={lowStockLabel}
876
+ style={{
877
+ position: 'absolute',
878
+ top: '2px',
879
+ right: '2px',
880
+ width: '6px',
881
+ height: '6px',
882
+ borderRadius: '50%',
883
+ background: 'var(--variant-option-low-stock, currentColor)',
884
+ pointerEvents: 'none'
885
+ }}
886
+ />
887
+ )}
888
+ </>
889
+ ) : (
890
+ body
891
+ );
892
+
893
+ // A colour or photo swatch carries no text, so the option needs an
894
+ // accessible name of its own.
895
+ const named = swatchNode ? { 'aria-label': label, title: label } : {};
896
+
897
+ if (disabled) {
898
+ const optionPk = getOptionProductPk(option);
899
+ const alertable =
900
+ stockAlertEnabled &&
901
+ isOptionSoldOut(option) &&
902
+ optionPk !== null;
903
+
904
+ if (alertable) {
905
+ const optionKey = `${groupKey}::${String(
906
+ option?.value || optionIndex
907
+ )}`;
908
+ const isOpen = openAlert?.key === optionKey;
909
+
910
+ return (
911
+ <button
912
+ key={String(option?.value || optionIndex)}
913
+ type="button"
914
+ data-variant-option="disabled"
915
+ {...named}
916
+ {...partAttrs('option', { disabled: true })}
917
+ /**
918
+ * Sold out but no longer a dead end, so it must not be
919
+ * announced as disabled: `aria-disabled` would tell a screen
920
+ * reader "you cannot use this" about the highest-intent
921
+ * control on the page. The `disabled` part state stays, so
922
+ * everything a brand styled for a sold-out swatch still lands
923
+ * (the generator matches `[data-state~="disabled"]`).
924
+ */
925
+ aria-label={[label, alertTriggerLabel]
926
+ .filter(Boolean)
927
+ .join(' — ')}
928
+ title={alertTriggerLabel || label}
929
+ aria-haspopup={
930
+ alertPresentation === 'modal' ? 'dialog' : undefined
931
+ }
932
+ aria-expanded={isOpen}
933
+ onClick={(event) => {
934
+ if (isOpen) {
935
+ closeAlert();
936
+ return;
937
+ }
938
+
939
+ openAlertFor(
940
+ {
941
+ key: optionKey,
942
+ groupKey,
943
+ productPk: optionPk,
944
+ optionLabel: label,
945
+ attributeName: String(group.attribute_name || '')
946
+ },
947
+ event.currentTarget
948
+ );
949
+ }}
950
+ style={{
951
+ ...optionStyle,
952
+ // A <button> brings its own chrome; the span it replaces
953
+ // had none, so the authored look is what survives.
954
+ background: 'transparent',
955
+ font: 'inherit',
956
+ textAlign: 'center',
957
+ cursor: 'pointer'
958
+ }}
959
+ >
960
+ {content}
961
+ </button>
962
+ );
963
+ }
964
+
965
+ return (
966
+ <span
967
+ key={String(option?.value || optionIndex)}
968
+ data-variant-option="disabled"
969
+ aria-disabled="true"
970
+ {...named}
971
+ {...partAttrs('option', { disabled: true })}
972
+ style={optionStyle}
973
+ >
974
+ {content}
975
+ </span>
976
+ );
977
+ }
978
+
979
+ return (
980
+ <NextLink
981
+ key={String(option?.value || optionIndex)}
982
+ href={url}
983
+ data-variant-option={selected ? 'selected' : 'default'}
984
+ aria-current={selected}
985
+ {...named}
986
+ {...partAttrs('option', { selected })}
987
+ onClick={(event) => {
988
+ if (isDesigner) event.preventDefault();
989
+ }}
990
+ style={optionStyle}
991
+ >
992
+ {content}
993
+ </NextLink>
994
+ );
995
+ };
996
+
997
+ return (
998
+ <div
999
+ key={groupKey}
1000
+ data-variant-group={groupKey}
1001
+ // Only emitted for a group a designer actually overrode, so a
1002
+ // composition on the block-wide layout keeps its exact markup.
1003
+ {...(override ? { 'data-variant-group-layout': groupLayout } : {})}
1004
+ {...partAttrs('group')}
1005
+ >
1006
+ {showGroupLabel && group.attribute_name && (
1007
+ <span
1008
+ data-variant-group-label=""
1009
+ {...partAttrs('label')}
1010
+ style={{
1011
+ display: 'block',
1012
+ marginBottom: '6px',
1013
+ fontSize: '0.85em',
1014
+ opacity: 0.75
1015
+ }}
1016
+ >
1017
+ {selectedValueNode ? (
1018
+ <>
1019
+ {group.attribute_name}
1020
+ {selectedValueSeparator}
1021
+ {selectedValueNode}
1022
+ </>
1023
+ ) : (
1024
+ group.attribute_name
1025
+ )}
1026
+ </span>
1027
+ )}
1028
+
1029
+ {groupLayout === 'dropdown' ? (
1030
+ <select
1031
+ data-variant-dropdown=""
1032
+ {...partAttrs('dropdown')}
1033
+ aria-label={group.attribute_name || 'Variant'}
1034
+ value={selectedUrl}
1035
+ onChange={(event) => {
1036
+ const url = event.target.value;
1037
+ // Designer clicks must never navigate the preview frame.
1038
+ if (isDesigner || !url) return;
1039
+ router.push(url);
1040
+ }}
1041
+ style={{
1042
+ width: '100%',
1043
+ padding: '8px 10px',
1044
+ font: 'inherit',
1045
+ color: 'inherit',
1046
+ background: 'transparent',
1047
+ border: '1px solid var(--variant-selector-border, #d1d5db)'
1048
+ }}
1049
+ >
1050
+ {!selectedUrl && <option value="">—</option>}
1051
+ {options.map((option, optionIndex) => (
1052
+ <option
1053
+ key={String(option?.value || optionIndex)}
1054
+ value={getOptionUrl(option)}
1055
+ disabled={isOptionDisabled(option)}
1056
+ >
1057
+ {String(option?.label || option?.value || '')}
1058
+ </option>
1059
+ ))}
1060
+ </select>
1061
+ ) : (
1062
+ <div
1063
+ {...partAttrs('option-list')}
1064
+ style={{
1065
+ display: 'flex',
1066
+ flexWrap: 'wrap',
1067
+ gap: `${safeOptionGap}px`
1068
+ }}
1069
+ >
1070
+ {options.map(renderOption)}
1071
+ </div>
1072
+ )}
1073
+
1074
+ {/* Inline: drawn in the group that owns the sold-out option, so
1075
+ the form reads as belonging to THAT size. The switch is read
1076
+ again here because it is per-breakpoint: an author who turns it
1077
+ off, or a canvas switched to a breakpoint where it is off, must
1078
+ not be left with a panel whose trigger no longer exists. */}
1079
+ {stockAlertEnabled &&
1080
+ alertPresentation === 'inline' &&
1081
+ openAlert?.groupKey === groupKey &&
1082
+ renderAlertPanel(false)}
1083
+ </div>
1084
+ );
1085
+ })}
1086
+
1087
+ {stockAlertEnabled && alertPresentation === 'modal' && openAlert !== null && (
1088
+ <>
1089
+ {/* A sibling of the dialog, never its ancestor: `aria-hidden` on a
1090
+ wrapper would hide the very form it exists to sit behind. */}
1091
+ <div
1092
+ data-variant-alert-backdrop=""
1093
+ {...partAttrs('alert-backdrop')}
1094
+ aria-hidden="true"
1095
+ onClick={closeAlert}
1096
+ style={{
1097
+ position: 'fixed',
1098
+ inset: 0,
1099
+ zIndex: 1000,
1100
+ background: 'var(--variant-alert-backdrop, rgba(0, 0, 0, 0.5))'
1101
+ }}
1102
+ />
1103
+ {renderAlertPanel(true)}
1104
+ </>
1105
+ )}
1106
+ </div>
1107
+ );
1108
+ };
1109
+
1110
+ export default VariantSelectorBlock;