@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,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;