@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.
- package/CHANGELOG.md +7 -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,417 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useAddProductMutation } from '@akinon/next/data/client/product';
|
|
5
|
+
import { basketApi } from '@akinon/next/data/client/basket';
|
|
6
|
+
import { useAppDispatch } from '@akinon/next/redux/hooks';
|
|
7
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
8
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
9
|
+
import {
|
|
10
|
+
DesignerPlaceholder,
|
|
11
|
+
ThemeProduct,
|
|
12
|
+
ThemeVariantGroup,
|
|
13
|
+
getVariantOptionStock,
|
|
14
|
+
isProductInStock,
|
|
15
|
+
pickProductField,
|
|
16
|
+
readToggle,
|
|
17
|
+
useThemeProduct
|
|
18
|
+
} from './product-block-utils';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_FEEDBACK_TIMEOUT = 2000;
|
|
21
|
+
|
|
22
|
+
const ALLOWED_ICONS = ['cart', 'bag', 'plus'];
|
|
23
|
+
const ALLOWED_ICON_POSITIONS = ['before', 'after'];
|
|
24
|
+
|
|
25
|
+
const ICON_PATHS: Record<string, string> = {
|
|
26
|
+
cart: 'M3 4h2l2.4 10.4a2 2 0 0 0 2 1.6h7.2a2 2 0 0 0 2-1.6L20.5 8H6M9 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm9 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z',
|
|
27
|
+
bag: 'M6 8h12l-1 12H7L6 8Zm3 0V6a3 3 0 0 1 6 0v2',
|
|
28
|
+
plus: 'M12 5v14M5 12h14'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const CartIcon = ({ name }: { name: string }) => (
|
|
32
|
+
<svg
|
|
33
|
+
width="18"
|
|
34
|
+
height="18"
|
|
35
|
+
viewBox="0 0 24 24"
|
|
36
|
+
fill="none"
|
|
37
|
+
stroke="currentColor"
|
|
38
|
+
strokeWidth="1.6"
|
|
39
|
+
strokeLinecap="round"
|
|
40
|
+
strokeLinejoin="round"
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
>
|
|
43
|
+
<path d={ICON_PATHS[name] || ICON_PATHS.cart} />
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const clamp = (value: number, min: number, max: number): number =>
|
|
48
|
+
Math.min(Math.max(value, min), max);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The stock behind what one click would add: the variant option the shopper is
|
|
52
|
+
* on when the matrix carries a number for it, otherwise the product's own —
|
|
53
|
+
* `pickProductField` already falls through to the selected variant there.
|
|
54
|
+
*
|
|
55
|
+
* null when commerce sent nothing. Unknown stock must never cap the stepper,
|
|
56
|
+
* the same way it never blocks the button: commerce validates on add.
|
|
57
|
+
*/
|
|
58
|
+
const readAvailableStock = (product: ThemeProduct | null): number | null => {
|
|
59
|
+
const groups = pickProductField<ThemeVariantGroup[]>(product, 'variants');
|
|
60
|
+
|
|
61
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
62
|
+
const selected = (group?.options || []).find(
|
|
63
|
+
(option) => option?.is_selected === true
|
|
64
|
+
);
|
|
65
|
+
const stock = selected ? getVariantOptionStock(selected) : null;
|
|
66
|
+
|
|
67
|
+
if (stock !== null) return stock;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const own = Number(pickProductField(product, 'stock'));
|
|
71
|
+
|
|
72
|
+
return Number.isFinite(own) ? own : null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Functional PDP block: adds the product on the page to the basket. Behavior
|
|
77
|
+
* is fixed (commerce add-to-basket); the editor owns the labels, the quantity
|
|
78
|
+
* added, the optional icon, the width and — through named parts — the styling
|
|
79
|
+
* of the button in every state.
|
|
80
|
+
*
|
|
81
|
+
* Parts: button, label, icon, row, quantity, quantity-button, quantity-input.
|
|
82
|
+
* Properties: label, addingLabel, addedLabel, errorLabel, outOfStockLabel,
|
|
83
|
+
* fullWidth, quantity, showIcon, icon, iconPosition, hideWhenOutOfStock,
|
|
84
|
+
* feedbackDuration, showQuantitySelector, quantityMin, quantityMax,
|
|
85
|
+
* quantityStep, quantityPosition, decreaseLabel, increaseLabel,
|
|
86
|
+
* decreaseAriaLabel, increaseAriaLabel.
|
|
87
|
+
*/
|
|
88
|
+
const AddToCartBlock = ({
|
|
89
|
+
block,
|
|
90
|
+
currentBreakpoint = 'desktop',
|
|
91
|
+
isDesigner = false
|
|
92
|
+
}: BlockRendererProps) => {
|
|
93
|
+
const product = useThemeProduct();
|
|
94
|
+
const dispatch = useAppDispatch();
|
|
95
|
+
const [addProduct, { isLoading }] = useAddProductMutation();
|
|
96
|
+
const [feedback, setFeedback] = useState<'idle' | 'added' | 'error'>('idle');
|
|
97
|
+
// null = the shopper has not touched the stepper, so the amount follows the
|
|
98
|
+
// authored quantity even after the author changes it in the panel.
|
|
99
|
+
const [pickedQuantity, setPickedQuantity] = useState<number | null>(null);
|
|
100
|
+
const feedbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
101
|
+
|
|
102
|
+
useEffect(
|
|
103
|
+
() => () => {
|
|
104
|
+
if (feedbackTimer.current) clearTimeout(feedbackTimer.current);
|
|
105
|
+
},
|
|
106
|
+
[]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const prop = (key: string, fallback: string) =>
|
|
110
|
+
String(
|
|
111
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const label = prop('label', 'Add to Cart');
|
|
115
|
+
const addingLabel = prop('addingLabel', 'Adding…');
|
|
116
|
+
const addedLabel = prop('addedLabel', 'Added');
|
|
117
|
+
const errorLabel = prop('errorLabel', 'Try again');
|
|
118
|
+
const outOfStockLabel = prop('outOfStockLabel', 'Out of Stock');
|
|
119
|
+
const fullWidth = readToggle(
|
|
120
|
+
block.properties?.fullWidth,
|
|
121
|
+
currentBreakpoint,
|
|
122
|
+
true
|
|
123
|
+
);
|
|
124
|
+
const showIcon = readToggle(
|
|
125
|
+
block.properties?.showIcon,
|
|
126
|
+
currentBreakpoint,
|
|
127
|
+
false
|
|
128
|
+
);
|
|
129
|
+
const iconValue = prop('icon', 'cart');
|
|
130
|
+
const icon = ALLOWED_ICONS.includes(iconValue) ? iconValue : 'cart';
|
|
131
|
+
const iconPositionValue = prop('iconPosition', 'before');
|
|
132
|
+
const iconPosition = ALLOWED_ICON_POSITIONS.includes(iconPositionValue)
|
|
133
|
+
? iconPositionValue
|
|
134
|
+
: 'before';
|
|
135
|
+
const hideWhenOutOfStock = readToggle(
|
|
136
|
+
block.properties?.hideWhenOutOfStock,
|
|
137
|
+
currentBreakpoint,
|
|
138
|
+
false
|
|
139
|
+
);
|
|
140
|
+
const quantityValue = Number(
|
|
141
|
+
getResponsiveValue(block.properties?.quantity, currentBreakpoint, 1)
|
|
142
|
+
);
|
|
143
|
+
const authoredQuantity =
|
|
144
|
+
Number.isFinite(quantityValue) && quantityValue >= 1
|
|
145
|
+
? Math.round(quantityValue)
|
|
146
|
+
: 1;
|
|
147
|
+
|
|
148
|
+
// The shopper-facing stepper. Off by default, in which case the amount added
|
|
149
|
+
// is still the fixed one the author set and no extra markup is rendered.
|
|
150
|
+
const showQuantitySelector = readToggle(
|
|
151
|
+
block.properties?.showQuantitySelector,
|
|
152
|
+
currentBreakpoint,
|
|
153
|
+
false
|
|
154
|
+
);
|
|
155
|
+
const readCount = (key: string, fallback: number, floor: number) => {
|
|
156
|
+
const raw = Math.round(
|
|
157
|
+
Number(getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback))
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
return Number.isFinite(raw) && raw >= floor ? raw : fallback;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const quantityMin = readCount('quantityMin', 1, 1);
|
|
164
|
+
const authoredMax = readCount('quantityMax', 10, 1);
|
|
165
|
+
// The authored ceiling is a design choice; stock is a fact. Letting a shopper
|
|
166
|
+
// pick 10 of a product with 3 left only earns them the generic error with
|
|
167
|
+
// nothing on screen explaining it, so the smaller of the two wins — but only
|
|
168
|
+
// when commerce actually sent a number. A max below the min would trap the
|
|
169
|
+
// stepper, so the min still wins last.
|
|
170
|
+
const availableStock = readAvailableStock(product);
|
|
171
|
+
const stockedMax =
|
|
172
|
+
availableStock !== null && availableStock > 0
|
|
173
|
+
? Math.min(authoredMax, Math.floor(availableStock))
|
|
174
|
+
: authoredMax;
|
|
175
|
+
const quantityMax = Math.max(stockedMax, quantityMin);
|
|
176
|
+
const quantityStep = readCount('quantityStep', 1, 1);
|
|
177
|
+
const quantityPositionValue = prop('quantityPosition', 'before');
|
|
178
|
+
// Same two sides the icon offers, so the panel reads consistently.
|
|
179
|
+
const quantityPosition = ALLOWED_ICON_POSITIONS.includes(
|
|
180
|
+
quantityPositionValue
|
|
181
|
+
)
|
|
182
|
+
? quantityPositionValue
|
|
183
|
+
: 'before';
|
|
184
|
+
const decreaseLabel = prop('decreaseLabel', '–');
|
|
185
|
+
const increaseLabel = prop('increaseLabel', '+');
|
|
186
|
+
const decreaseAriaLabel = prop('decreaseAriaLabel', 'Decrease quantity');
|
|
187
|
+
const increaseAriaLabel = prop('increaseAriaLabel', 'Increase quantity');
|
|
188
|
+
|
|
189
|
+
// Untouched, the stepper starts on the amount the author asked for: setting
|
|
190
|
+
// "Quantity Added" to 2 and being handed 1 reads as the property being
|
|
191
|
+
// ignored. Clamped, because the authored amount can sit outside the range —
|
|
192
|
+
// or above what is left in stock.
|
|
193
|
+
const pickedAmount = clamp(
|
|
194
|
+
pickedQuantity ?? authoredQuantity,
|
|
195
|
+
quantityMin,
|
|
196
|
+
quantityMax
|
|
197
|
+
);
|
|
198
|
+
const quantity = showQuantitySelector ? pickedAmount : authoredQuantity;
|
|
199
|
+
|
|
200
|
+
const feedbackDurationValue = Number(
|
|
201
|
+
getResponsiveValue(
|
|
202
|
+
block.properties?.feedbackDuration,
|
|
203
|
+
currentBreakpoint,
|
|
204
|
+
DEFAULT_FEEDBACK_TIMEOUT
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
const feedbackDuration =
|
|
208
|
+
Number.isFinite(feedbackDurationValue) && feedbackDurationValue > 0
|
|
209
|
+
? feedbackDurationValue
|
|
210
|
+
: DEFAULT_FEEDBACK_TIMEOUT;
|
|
211
|
+
|
|
212
|
+
const productPk = Number(pickProductField(product, 'pk'));
|
|
213
|
+
const hasProduct = Number.isFinite(productPk) && productPk > 0;
|
|
214
|
+
|
|
215
|
+
if (!hasProduct) {
|
|
216
|
+
return isDesigner ? (
|
|
217
|
+
<DesignerPlaceholder
|
|
218
|
+
label="Add to cart (no product)"
|
|
219
|
+
attribute="data-add-to-cart"
|
|
220
|
+
/>
|
|
221
|
+
) : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const inStock = isProductInStock(product);
|
|
225
|
+
|
|
226
|
+
// Brands that would rather show nothing than a dead button get the block
|
|
227
|
+
// removed outright; the author still sees it in the designer.
|
|
228
|
+
if (!inStock && hideWhenOutOfStock) {
|
|
229
|
+
return isDesigner ? (
|
|
230
|
+
<DesignerPlaceholder
|
|
231
|
+
label="Add to cart (hidden while out of stock)"
|
|
232
|
+
attribute="data-add-to-cart"
|
|
233
|
+
/>
|
|
234
|
+
) : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const disabled = !inStock || isLoading;
|
|
238
|
+
|
|
239
|
+
const currentLabel = !inStock
|
|
240
|
+
? outOfStockLabel
|
|
241
|
+
: isLoading
|
|
242
|
+
? addingLabel
|
|
243
|
+
: feedback === 'added'
|
|
244
|
+
? addedLabel
|
|
245
|
+
: feedback === 'error'
|
|
246
|
+
? errorLabel
|
|
247
|
+
: label;
|
|
248
|
+
|
|
249
|
+
const scheduleReset = () => {
|
|
250
|
+
if (feedbackTimer.current) clearTimeout(feedbackTimer.current);
|
|
251
|
+
feedbackTimer.current = setTimeout(
|
|
252
|
+
() => setFeedback('idle'),
|
|
253
|
+
feedbackDuration
|
|
254
|
+
);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const handleClick = async () => {
|
|
258
|
+
// A click inside the designer must never touch the shopper's real basket.
|
|
259
|
+
if (isDesigner || disabled) return;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await addProduct({
|
|
263
|
+
product: productPk,
|
|
264
|
+
quantity,
|
|
265
|
+
attributes: {}
|
|
266
|
+
}).unwrap();
|
|
267
|
+
|
|
268
|
+
// addProduct only invalidates MiniBasket, so the full basket query (used
|
|
269
|
+
// by the mini-basket chrome block) has to be refreshed explicitly.
|
|
270
|
+
dispatch(basketApi.util.invalidateTags(['Basket', 'MiniBasket']));
|
|
271
|
+
setFeedback('added');
|
|
272
|
+
} catch {
|
|
273
|
+
setFeedback('error');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
scheduleReset();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const iconNode = showIcon ? (
|
|
280
|
+
<span
|
|
281
|
+
data-add-to-cart-icon=""
|
|
282
|
+
{...partAttrs('icon')}
|
|
283
|
+
style={{ display: 'inline-flex', flex: '0 0 auto' }}
|
|
284
|
+
>
|
|
285
|
+
<CartIcon name={icon} />
|
|
286
|
+
</span>
|
|
287
|
+
) : null;
|
|
288
|
+
|
|
289
|
+
const buttonNode = (
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
data-add-to-cart=""
|
|
293
|
+
data-add-to-cart-state={
|
|
294
|
+
!inStock ? 'out-of-stock' : isLoading ? 'busy' : feedback
|
|
295
|
+
}
|
|
296
|
+
{...partAttrs('button', { loading: isLoading, disabled })}
|
|
297
|
+
disabled={disabled}
|
|
298
|
+
aria-busy={isLoading}
|
|
299
|
+
onClick={handleClick}
|
|
300
|
+
style={{
|
|
301
|
+
display: 'inline-flex',
|
|
302
|
+
alignItems: 'center',
|
|
303
|
+
justifyContent: 'center',
|
|
304
|
+
gap: '8px',
|
|
305
|
+
width: fullWidth ? '100%' : 'auto',
|
|
306
|
+
padding: 0,
|
|
307
|
+
margin: 0,
|
|
308
|
+
border: 'none',
|
|
309
|
+
background: 'transparent',
|
|
310
|
+
color: 'inherit',
|
|
311
|
+
font: 'inherit',
|
|
312
|
+
textAlign: 'center',
|
|
313
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
314
|
+
// The busy state is carried by the label and opacity: a keyframed
|
|
315
|
+
// spinner would need a <style> tag inside the button, which is invalid
|
|
316
|
+
// markup and hydrates inconsistently across React versions.
|
|
317
|
+
opacity: !inStock ? 0.6 : isLoading ? 0.7 : 1,
|
|
318
|
+
transition: 'opacity 150ms ease',
|
|
319
|
+
// A `width: 100%` flex item beside the stepper would overflow the row,
|
|
320
|
+
// so the button becomes a flex child only when the stepper is there.
|
|
321
|
+
...(showQuantitySelector
|
|
322
|
+
? { flex: fullWidth ? '1 1 auto' : '0 0 auto', minWidth: 0 }
|
|
323
|
+
: null)
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
{iconPosition === 'before' && iconNode}
|
|
327
|
+
<span data-add-to-cart-label="" {...partAttrs('label')}>
|
|
328
|
+
{currentLabel}
|
|
329
|
+
</span>
|
|
330
|
+
{iconPosition === 'after' && iconNode}
|
|
331
|
+
</button>
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
if (!showQuantitySelector) return buttonNode;
|
|
335
|
+
|
|
336
|
+
const stepQuantity = (delta: number) => {
|
|
337
|
+
// Clicking inside the designer must not move the preview's state around.
|
|
338
|
+
if (isDesigner) return;
|
|
339
|
+
|
|
340
|
+
setPickedQuantity(
|
|
341
|
+
clamp(quantity + delta * quantityStep, quantityMin, quantityMax)
|
|
342
|
+
);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const atMin = quantity <= quantityMin;
|
|
346
|
+
const atMax = quantity >= quantityMax;
|
|
347
|
+
const stepButtonStyle: React.CSSProperties = {
|
|
348
|
+
background: 'none',
|
|
349
|
+
border: 'none',
|
|
350
|
+
cursor: 'pointer',
|
|
351
|
+
color: 'inherit',
|
|
352
|
+
font: 'inherit',
|
|
353
|
+
padding: '2px 6px'
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// Same part names as the basket line's stepper, so the two are one control
|
|
357
|
+
// to a designer rather than two vocabularies that happen to look alike.
|
|
358
|
+
const stepper = (
|
|
359
|
+
<div
|
|
360
|
+
data-add-to-cart-quantity=""
|
|
361
|
+
{...partAttrs('quantity', { disabled })}
|
|
362
|
+
style={{
|
|
363
|
+
display: 'inline-flex',
|
|
364
|
+
alignItems: 'center',
|
|
365
|
+
gap: '8px',
|
|
366
|
+
flex: '0 0 auto',
|
|
367
|
+
border: '1px solid var(--add-to-cart-border, #e5e7eb)',
|
|
368
|
+
borderRadius: '4px',
|
|
369
|
+
padding: '2px 6px'
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
<button
|
|
373
|
+
type="button"
|
|
374
|
+
aria-label={decreaseAriaLabel}
|
|
375
|
+
disabled={disabled || atMin}
|
|
376
|
+
onClick={() => stepQuantity(-1)}
|
|
377
|
+
{...partAttrs('quantity-button', { disabled: disabled || atMin })}
|
|
378
|
+
style={stepButtonStyle}
|
|
379
|
+
>
|
|
380
|
+
{decreaseLabel}
|
|
381
|
+
</button>
|
|
382
|
+
{/* Announced on change so a screen reader hears the new amount. */}
|
|
383
|
+
<span {...partAttrs('quantity-input')} aria-live="polite">
|
|
384
|
+
{quantity}
|
|
385
|
+
</span>
|
|
386
|
+
<button
|
|
387
|
+
type="button"
|
|
388
|
+
aria-label={increaseAriaLabel}
|
|
389
|
+
disabled={disabled || atMax}
|
|
390
|
+
onClick={() => stepQuantity(1)}
|
|
391
|
+
{...partAttrs('quantity-button', { disabled: disabled || atMax })}
|
|
392
|
+
style={stepButtonStyle}
|
|
393
|
+
>
|
|
394
|
+
{increaseLabel}
|
|
395
|
+
</button>
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<div
|
|
401
|
+
data-add-to-cart-row=""
|
|
402
|
+
{...partAttrs('row')}
|
|
403
|
+
style={{
|
|
404
|
+
display: 'flex',
|
|
405
|
+
alignItems: 'center',
|
|
406
|
+
gap: '8px',
|
|
407
|
+
width: fullWidth ? '100%' : 'auto'
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
{quantityPosition === 'before' && stepper}
|
|
411
|
+
{buttonNode}
|
|
412
|
+
{quantityPosition === 'after' && stepper}
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
export default AddToCartBlock;
|