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