@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +15 -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,240 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { useUpdatePasswordMutation } from '@akinon/next/data/client/account';
5
+ import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { BlockRendererProps } from './block-renderer-registry';
7
+ import { readToggle } from './product-block-utils';
8
+
9
+ const EMPTY_FORM = {
10
+ old_password: '',
11
+ new_password1: '',
12
+ new_password2: ''
13
+ };
14
+
15
+ /**
16
+ * Where the submit button sits in the form's flex column. `stretch` maps to no
17
+ * declaration at all: the button has always inherited the container's
18
+ * alignment, and writing the value out would override a brand that centres its
19
+ * form from custom CSS.
20
+ */
21
+ const SUBMIT_ALIGNMENTS: Record<string, string> = {
22
+ left: 'flex-start',
23
+ center: 'center',
24
+ right: 'flex-end'
25
+ };
26
+
27
+ /**
28
+ * Commerce rejects a password change with a field-keyed payload
29
+ * (`{ old_password: ['...'] }`) rather than a flat message, so the first usable
30
+ * string is dug out instead of showing "[object Object]" to the shopper. The
31
+ * fallback is passed in because the wording is an editor property.
32
+ */
33
+ const readPasswordError = (error: unknown, fallback: string): string => {
34
+ const data = (error as { data?: unknown })?.data;
35
+
36
+ if (typeof data === 'string') return data;
37
+
38
+ if (data && typeof data === 'object') {
39
+ for (const value of Object.values(data as Record<string, unknown>)) {
40
+ if (typeof value === 'string') return value;
41
+ if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
42
+ }
43
+ }
44
+
45
+ return fallback;
46
+ };
47
+
48
+ /**
49
+ * Functional account block: the change-password form. Behavior is fixed
50
+ * (commerce password service); the editor owns every caption and the styling.
51
+ * Properties: submitLabel, submitAlign, showLabels, currentPasswordLabel,
52
+ * newPasswordLabel, confirmPasswordLabel, helpLabel, mismatchMessage,
53
+ * errorMessage, successMessage.
54
+ * Parts: form, field, label, input, help, error, success, submit.
55
+ */
56
+ const ChangePasswordFormBlock = ({
57
+ block,
58
+ currentBreakpoint = 'desktop',
59
+ isDesigner = false
60
+ }: BlockRendererProps) => {
61
+ const text = (key: string, fallback: string) =>
62
+ String(
63
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
64
+ );
65
+
66
+ const submitLabel = text('submitLabel', 'Change password');
67
+ const submitAlign = text('submitAlign', 'stretch');
68
+ const currentPasswordLabel = text('currentPasswordLabel', 'Current password');
69
+ const newPasswordLabel = text('newPasswordLabel', 'New password');
70
+ const confirmPasswordLabel = text(
71
+ 'confirmPasswordLabel',
72
+ 'Confirm new password'
73
+ );
74
+ const helpLabel = text('helpLabel', '');
75
+ const mismatchMessage = text(
76
+ 'mismatchMessage',
77
+ 'The new passwords do not match.'
78
+ );
79
+ const errorMessage = text(
80
+ 'errorMessage',
81
+ 'Your password could not be changed.'
82
+ );
83
+ const successMessage = text(
84
+ 'successMessage',
85
+ 'Your password has been changed.'
86
+ );
87
+ const showLabels = readToggle(
88
+ block.properties?.showLabels,
89
+ currentBreakpoint,
90
+ true
91
+ );
92
+
93
+ const [updatePassword, { isLoading }] = useUpdatePasswordMutation();
94
+ const [values, setValues] = useState(EMPTY_FORM);
95
+ const [error, setError] = useState('');
96
+ const [isDone, setIsDone] = useState(false);
97
+
98
+ const setField =
99
+ (key: keyof typeof EMPTY_FORM) =>
100
+ (event: React.ChangeEvent<HTMLInputElement>) => {
101
+ const nextValue = event.target.value;
102
+ setError('');
103
+ setIsDone(false);
104
+ setValues((current) => ({ ...current, [key]: nextValue }));
105
+ };
106
+
107
+ const submit = async (event: React.FormEvent) => {
108
+ event.preventDefault();
109
+
110
+ // A submit inside the designer must never change the shopper's password.
111
+ if (isDesigner || isLoading) return;
112
+
113
+ setError('');
114
+ setIsDone(false);
115
+
116
+ // Checked before the request so the obvious mistake costs a round trip to
117
+ // nobody; commerce validates the same rule server side regardless.
118
+ if (values.new_password1 !== values.new_password2) {
119
+ setError(mismatchMessage);
120
+
121
+ return;
122
+ }
123
+
124
+ try {
125
+ await updatePassword(values).unwrap();
126
+ setValues(EMPTY_FORM);
127
+ setIsDone(true);
128
+ } catch (requestError) {
129
+ setError(readPasswordError(requestError, errorMessage));
130
+ }
131
+ };
132
+
133
+ const inputStyle: React.CSSProperties = {
134
+ width: '100%',
135
+ border: '1px solid var(--change-password-form-border, #d1d5db)',
136
+ background: 'transparent',
137
+ color: 'inherit',
138
+ padding: '10px 12px',
139
+ outline: 'none'
140
+ };
141
+
142
+ const field = (
143
+ key: keyof typeof EMPTY_FORM,
144
+ label: string,
145
+ autoComplete: string
146
+ ) => (
147
+ <label
148
+ data-change-password-form-field={key}
149
+ {...partAttrs('field')}
150
+ style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
151
+ >
152
+ {showLabels && <span {...partAttrs('label')}>{label}</span>}
153
+ <input
154
+ type="password"
155
+ name={key}
156
+ value={values[key]}
157
+ onChange={setField(key)}
158
+ autoComplete={autoComplete}
159
+ required
160
+ // The caption is still announced when it is hidden visually, otherwise
161
+ // hiding labels would leave three identical password inputs.
162
+ aria-label={showLabels ? undefined : label}
163
+ {...partAttrs('input', { invalid: Boolean(error) })}
164
+ style={inputStyle}
165
+ />
166
+ </label>
167
+ );
168
+
169
+ return (
170
+ <form
171
+ data-change-password-form=""
172
+ data-change-password-form-state={
173
+ isLoading ? 'busy' : error ? 'error' : isDone ? 'done' : 'idle'
174
+ }
175
+ onSubmit={submit}
176
+ {...partAttrs('form', {
177
+ loading: isLoading,
178
+ invalid: Boolean(error)
179
+ })}
180
+ style={{
181
+ display: 'flex',
182
+ flexDirection: 'column',
183
+ gap: 'var(--change-password-form-gap, 12px)'
184
+ }}
185
+ >
186
+ {field('old_password', currentPasswordLabel, 'current-password')}
187
+ {field('new_password1', newPasswordLabel, 'new-password')}
188
+ {field('new_password2', confirmPasswordLabel, 'new-password')}
189
+
190
+ {helpLabel && (
191
+ <div data-change-password-form-help="" {...partAttrs('help')}>
192
+ {helpLabel}
193
+ </div>
194
+ )}
195
+
196
+ {error && (
197
+ <div
198
+ data-change-password-form-error=""
199
+ role="alert"
200
+ {...partAttrs('error', { invalid: true })}
201
+ >
202
+ {error}
203
+ </div>
204
+ )}
205
+
206
+ {isDone && (
207
+ <div
208
+ data-change-password-form-success=""
209
+ role="status"
210
+ {...partAttrs('success')}
211
+ >
212
+ {successMessage}
213
+ </div>
214
+ )}
215
+
216
+ <button
217
+ type="submit"
218
+ data-change-password-form-submit=""
219
+ disabled={isLoading}
220
+ aria-busy={isLoading}
221
+ {...partAttrs('submit', { loading: isLoading })}
222
+ style={{
223
+ padding: '12px 20px',
224
+ background: 'var(--change-password-form-cta-bg, #111827)',
225
+ color: 'var(--change-password-form-cta-color, #ffffff)',
226
+ border: 'none',
227
+ cursor: isLoading ? 'not-allowed' : 'pointer',
228
+ opacity: isLoading ? 0.7 : 1,
229
+ ...(SUBMIT_ALIGNMENTS[submitAlign]
230
+ ? { alignSelf: SUBMIT_ALIGNMENTS[submitAlign] }
231
+ : null)
232
+ }}
233
+ >
234
+ {submitLabel}
235
+ </button>
236
+ </form>
237
+ );
238
+ };
239
+
240
+ export default ChangePasswordFormBlock;
@@ -0,0 +1,288 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { usePathname, useSearchParams } from 'next/navigation';
5
+ import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { hasItemTemplate, ItemTemplateList } from './item-template';
7
+ import { readToggle } from './product-block-utils';
8
+ import { BlockRendererProps } from './block-renderer-registry';
9
+
10
+ const DEFAULT_STEPS = 'Address, Shipping, Payment';
11
+
12
+ /**
13
+ * URL segments that mean "the flow is over". pz sends the shopper to
14
+ * /orders/completed/<token> after payment, so the last step is highlighted
15
+ * there without the author having to configure anything.
16
+ */
17
+ const COMPLETION_SEGMENTS = new Set([
18
+ 'completed',
19
+ 'complete',
20
+ 'success',
21
+ 'confirmation',
22
+ 'thank-you'
23
+ ]);
24
+
25
+ const slugify = (value: string) =>
26
+ value
27
+ .trim()
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, '-')
30
+ .replace(/^-+|-+$/g, '');
31
+
32
+ /**
33
+ * Resolves the active step from the URL. Returns null when the URL says
34
+ * nothing about the step, which is the normal case on pz checkout: the flow
35
+ * itself is redux-driven and never writes the step into the address bar.
36
+ */
37
+ const resolveStepFromUrl = (
38
+ slugs: string[],
39
+ pathname: string,
40
+ stepParam: string | null
41
+ ): number | null => {
42
+ if (stepParam) {
43
+ const numeric = Number(stepParam);
44
+
45
+ if (Number.isFinite(numeric) && numeric >= 1 && numeric <= slugs.length) {
46
+ return Math.round(numeric) - 1;
47
+ }
48
+
49
+ const matched = slugs.indexOf(slugify(stepParam));
50
+ if (matched >= 0) return matched;
51
+ }
52
+
53
+ const segments = String(pathname || '')
54
+ .split('/')
55
+ .filter(Boolean)
56
+ .map(slugify);
57
+
58
+ // Scanned from the end: the deepest segment is the most specific one, so
59
+ // /orders/checkout/payment resolves to "payment" and not to "checkout".
60
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
61
+ const segment = segments[index];
62
+
63
+ if (COMPLETION_SEGMENTS.has(segment)) return slugs.length - 1;
64
+
65
+ const matched = slugs.indexOf(segment);
66
+ if (matched >= 0) return matched;
67
+ }
68
+
69
+ return null;
70
+ };
71
+
72
+ const CheckGlyph = () => (
73
+ <svg
74
+ width="12"
75
+ height="12"
76
+ viewBox="0 0 24 24"
77
+ fill="none"
78
+ stroke="currentColor"
79
+ strokeWidth="3"
80
+ strokeLinecap="round"
81
+ strokeLinejoin="round"
82
+ >
83
+ <path d="M4 12l6 6L20 6" />
84
+ </svg>
85
+ );
86
+
87
+ /**
88
+ * Presentational checkout step indicator. It reflects progress, it does not
89
+ * produce it: there are no links, no buttons and no dispatches, so clicking a
90
+ * step cannot move the shopper between checkout steps. Step transitions stay
91
+ * owned by the checkout variant (which validates addresses, shipping options
92
+ * and payment state before advancing).
93
+ *
94
+ * The active step comes from the URL when the URL carries one (a `step` query
95
+ * parameter, a matching path segment, or a completion segment). In the
96
+ * designer the `activeStep` property wins instead, so an author can preview
97
+ * every state without a live checkout behind the preview.
98
+ *
99
+ * Parts: root, step (states: selected = current, checked = done,
100
+ * disabled = upcoming), step-index, step-label, connector, empty. A child
101
+ * marked as the item template replaces the built-in step.
102
+ */
103
+ const CheckoutStepsBlock = ({
104
+ block,
105
+ placeholderId,
106
+ sectionId,
107
+ selectedBlockId = null,
108
+ currentBreakpoint = 'desktop',
109
+ isDesigner = false
110
+ }: BlockRendererProps) => {
111
+ const prop = (key: string, fallback: string) =>
112
+ String(
113
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
114
+ );
115
+
116
+ const rawSteps = prop('steps', DEFAULT_STEPS);
117
+ const steps = rawSteps
118
+ .split(',')
119
+ .map((entry) => entry.trim())
120
+ .filter(Boolean);
121
+
122
+ const rawActiveStep = Number(
123
+ getResponsiveValue(block.properties?.activeStep, currentBreakpoint, 1)
124
+ );
125
+
126
+ const showIndex = readToggle(
127
+ block.properties?.showIndex,
128
+ currentBreakpoint,
129
+ true
130
+ );
131
+ const showLabel = readToggle(
132
+ block.properties?.showLabel,
133
+ currentBreakpoint,
134
+ true
135
+ );
136
+ const showConnector = readToggle(
137
+ block.properties?.showConnector,
138
+ currentBreakpoint,
139
+ false
140
+ );
141
+ const doneIcon = prop('doneIcon', 'number');
142
+ const emptyLabel = prop('emptyLabel', '').trim();
143
+
144
+ const pathname = usePathname();
145
+ const searchParams = useSearchParams();
146
+
147
+ const slugs = steps.map(slugify);
148
+ const propertyIndex = Number.isFinite(rawActiveStep)
149
+ ? Math.min(
150
+ Math.max(steps.length - 1, 0),
151
+ Math.max(0, Math.round(rawActiveStep) - 1)
152
+ )
153
+ : 0;
154
+ const urlIndex = isDesigner
155
+ ? null
156
+ : resolveStepFromUrl(slugs, pathname || '', searchParams?.get('step') ?? null);
157
+
158
+ const activeIndex = urlIndex ?? propertyIndex;
159
+
160
+ const stepState = (index: number): 'done' | 'current' | 'upcoming' =>
161
+ index < activeIndex ? 'done' : index === activeIndex ? 'current' : 'upcoming';
162
+
163
+ // Plain objects so a designer binds item.label / item.index / item.state in
164
+ // their own step layout.
165
+ const templateSteps = steps.map((label, index) => ({
166
+ key: `${slugs[index]}-${index}`,
167
+ label,
168
+ index: index + 1,
169
+ slug: slugs[index],
170
+ state: stepState(index)
171
+ }));
172
+
173
+ const customSteps = hasItemTemplate(block, currentBreakpoint) ? (
174
+ <ItemTemplateList
175
+ block={block}
176
+ items={templateSteps as unknown as Record<string, unknown>[]}
177
+ placeholderId={placeholderId}
178
+ sectionId={sectionId}
179
+ isDesigner={isDesigner}
180
+ selectedBlockId={selectedBlockId}
181
+ currentBreakpoint={currentBreakpoint}
182
+ />
183
+ ) : null;
184
+
185
+ if (steps.length === 0) {
186
+ if (!isDesigner && !emptyLabel) return null;
187
+
188
+ return (
189
+ <div
190
+ data-checkout-steps=""
191
+ data-checkout-steps-state="empty"
192
+ {...partAttrs('empty', { empty: true })}
193
+ >
194
+ {emptyLabel || (isDesigner ? 'Checkout steps (no steps configured)' : '')}
195
+ </div>
196
+ );
197
+ }
198
+
199
+ return (
200
+ <ol
201
+ data-checkout-steps=""
202
+ data-checkout-steps-active={slugs[activeIndex] || ''}
203
+ {...partAttrs('root')}
204
+ style={{
205
+ display: 'flex',
206
+ alignItems: 'center',
207
+ gap: 'var(--checkout-steps-gap, 16px)',
208
+ listStyle: 'none',
209
+ margin: 0,
210
+ padding: 0
211
+ }}
212
+ >
213
+ {customSteps ??
214
+ steps.map((label, index) => {
215
+ const state = stepState(index);
216
+ const isDone = state === 'done';
217
+
218
+ return (
219
+ <li
220
+ key={`${slugs[index]}-${index}`}
221
+ data-checkout-step=""
222
+ data-checkout-step-state={state}
223
+ aria-current={state === 'current' ? 'step' : undefined}
224
+ {...partAttrs('step', {
225
+ selected: state === 'current',
226
+ checked: isDone,
227
+ disabled: state === 'upcoming'
228
+ })}
229
+ style={{
230
+ display: 'inline-flex',
231
+ alignItems: 'center',
232
+ gap: '8px',
233
+ opacity: state === 'upcoming' ? 0.5 : 1,
234
+ fontWeight: state === 'current' ? 600 : 400
235
+ }}
236
+ >
237
+ {showIndex && (
238
+ <span
239
+ data-checkout-step-index=""
240
+ aria-hidden="true"
241
+ {...partAttrs('step-index', {
242
+ selected: state === 'current',
243
+ checked: isDone,
244
+ disabled: state === 'upcoming'
245
+ })}
246
+ style={{
247
+ display: 'inline-flex',
248
+ alignItems: 'center',
249
+ justifyContent: 'center',
250
+ width: '24px',
251
+ height: '24px',
252
+ borderRadius: '50%',
253
+ border: '1px solid currentColor',
254
+ fontSize: '0.8em',
255
+ flex: '0 0 auto'
256
+ }}
257
+ >
258
+ {isDone && doneIcon === 'check' ? <CheckGlyph /> : index + 1}
259
+ </span>
260
+ )}
261
+ {showLabel && (
262
+ <span data-checkout-step-label="" {...partAttrs('step-label')}>
263
+ {label}
264
+ </span>
265
+ )}
266
+ {showConnector && index < steps.length - 1 && (
267
+ <span
268
+ data-checkout-step-connector=""
269
+ aria-hidden="true"
270
+ {...partAttrs('connector', { checked: isDone })}
271
+ style={{
272
+ display: 'block',
273
+ width: '24px',
274
+ height: '1px',
275
+ background: 'currentColor',
276
+ opacity: 0.35,
277
+ flex: '0 0 auto'
278
+ }}
279
+ />
280
+ )}
281
+ </li>
282
+ );
283
+ })}
284
+ </ol>
285
+ );
286
+ };
287
+
288
+ export default CheckoutStepsBlock;