@akinon/pz-theme 2.0.37-rc.0 → 2.0.38-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -31
- package/package.json +4 -4
- package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
- package/src/__tests__/custom-css.test.ts +181 -0
- package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
- package/src/__tests__/facet-filters-block.test.tsx +707 -0
- package/src/__tests__/item-template.test.tsx +184 -0
- package/src/__tests__/listing-controls.test.tsx +961 -0
- package/src/__tests__/part-style-scope.test.tsx +229 -0
- package/src/__tests__/part-styles.test.ts +311 -0
- package/src/__tests__/registry-parity.test.ts +42 -0
- package/src/__tests__/row-visibility.test.ts +672 -0
- package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
- package/src/__tests__/theme-block-html-id.test.tsx +167 -0
- package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
- package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
- package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
- package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
- package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
- package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
- package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
- package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
- package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
- package/src/blocks/__tests__/share-block.test.tsx +392 -0
- package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
- package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
- package/src/blocks/__tests__/variant-selector-golden.json +20 -0
- package/src/blocks/account-menu-block.tsx +264 -0
- package/src/blocks/add-to-cart-block.tsx +417 -0
- package/src/blocks/address-list-block.tsx +348 -0
- package/src/blocks/applied-filters-block.tsx +288 -0
- package/src/blocks/auth-form-block.tsx +295 -0
- package/src/blocks/basket-items-block.tsx +457 -0
- package/src/blocks/basket-summary-block.tsx +404 -0
- package/src/blocks/block-renderer-registry.tsx +94 -0
- package/src/blocks/change-password-form-block.tsx +240 -0
- package/src/blocks/checkout-steps-block.tsx +288 -0
- package/src/blocks/checkout-summary-block.tsx +449 -0
- package/src/blocks/countdown-block.tsx +182 -0
- package/src/blocks/coupon-list-block.tsx +292 -0
- package/src/blocks/facet-filters-block.tsx +1686 -0
- package/src/blocks/faq-accordion-block.tsx +358 -0
- package/src/blocks/favorite-button-block.tsx +252 -0
- package/src/blocks/favourites-grid-block.tsx +510 -0
- package/src/blocks/forgot-password-block.tsx +155 -0
- package/src/blocks/item-template-utils.ts +131 -0
- package/src/blocks/item-template.tsx +164 -0
- package/src/blocks/listing-breadcrumb-block.tsx +175 -0
- package/src/blocks/listing-context.ts +850 -0
- package/src/blocks/listing-pagination-block.tsx +923 -0
- package/src/blocks/listing-result-count-block.tsx +126 -0
- package/src/blocks/listing-title-block.tsx +115 -0
- package/src/blocks/oauth-buttons-block.tsx +205 -0
- package/src/blocks/order-detail-block.tsx +474 -0
- package/src/blocks/order-list-block.tsx +274 -0
- package/src/blocks/order-success-block.tsx +641 -0
- package/src/blocks/order-tracking-block.tsx +533 -0
- package/src/blocks/product-attributes-block.tsx +307 -0
- package/src/blocks/product-block-utils.tsx +639 -0
- package/src/blocks/product-breadcrumb-block.tsx +215 -0
- package/src/blocks/product-description-block.tsx +169 -0
- package/src/blocks/product-gallery-block.tsx +1523 -0
- package/src/blocks/product-grid-block.tsx +966 -0
- package/src/blocks/product-price-block.tsx +373 -0
- package/src/blocks/product-recommendations-block.tsx +1433 -0
- package/src/blocks/product-title-block.tsx +129 -0
- package/src/blocks/profile-form-block.tsx +297 -0
- package/src/blocks/recently-viewed-store.ts +201 -0
- package/src/blocks/register-form-block.tsx +362 -0
- package/src/blocks/share-block.tsx +667 -0
- package/src/blocks/size-guide-block.tsx +313 -0
- package/src/blocks/sort-select-block.tsx +636 -0
- package/src/blocks/stock-status-block.tsx +168 -0
- package/src/blocks/store-locator-block.tsx +1184 -0
- package/src/blocks/variant-selector-block.tsx +1110 -0
- package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
|
@@ -0,0 +1,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;
|