@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,295 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import { useRouter } from 'next/navigation';
|
|
6
|
+
import { signIn } from 'next-auth/react';
|
|
7
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
8
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
9
|
+
import { readToggle } from './product-block-utils';
|
|
10
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extracts a readable message out of the next-auth error string. pz brands
|
|
14
|
+
* serialise an AuthError[] into `error`, but a plain string is also possible,
|
|
15
|
+
* so both shapes are handled.
|
|
16
|
+
*/
|
|
17
|
+
const readAuthError = (error?: string | null): string => {
|
|
18
|
+
if (!error) return '';
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(error);
|
|
22
|
+
|
|
23
|
+
if (!Array.isArray(parsed)) return String(error);
|
|
24
|
+
|
|
25
|
+
const messages: string[] = [];
|
|
26
|
+
|
|
27
|
+
parsed.forEach((entry) => {
|
|
28
|
+
const data = (entry as { data?: unknown })?.data;
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(data)) {
|
|
31
|
+
data.forEach((item) => {
|
|
32
|
+
if (typeof item === 'string') {
|
|
33
|
+
messages.push(item);
|
|
34
|
+
} else if (item && typeof item === 'object') {
|
|
35
|
+
const value = (item as { value?: unknown }).value;
|
|
36
|
+
if (typeof value === 'string') messages.push(value);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
} else if (typeof data === 'string') {
|
|
40
|
+
messages.push(data);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return messages.join(' ') || String(error);
|
|
45
|
+
} catch {
|
|
46
|
+
return String(error);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Functional auth block: login or register form driven by props.mode.
|
|
52
|
+
*
|
|
53
|
+
* Submits through next-auth `signIn(providerId, { formType, ... })`, which is
|
|
54
|
+
* how pz storefronts authenticate — the brand's own /api/auth route decides
|
|
55
|
+
* which commerce endpoint is called. Both the provider id and the formType
|
|
56
|
+
* values are editor properties because they differ per brand: the core
|
|
57
|
+
* provider maps formType onto the `user` url keys ('login' | 'register'),
|
|
58
|
+
* while brands with a custom [...nextauth] route (superstep) use the url path
|
|
59
|
+
* segment ('login' | 'registration').
|
|
60
|
+
* Properties: mode, submitLabel, busyLabel, providerId, loginFormType,
|
|
61
|
+
* registerFormType, callbackUrl, emailLabel, passwordLabel, firstNameLabel,
|
|
62
|
+
* lastNameLabel, the matching *Placeholder keys, showLabels, errorLabel,
|
|
63
|
+
* showForgotPassword, forgotPasswordLabel, forgotPasswordUrl.
|
|
64
|
+
* Parts: form, field, label, input, error, submit, link.
|
|
65
|
+
*/
|
|
66
|
+
const AuthFormBlock = ({
|
|
67
|
+
block,
|
|
68
|
+
currentBreakpoint = 'desktop',
|
|
69
|
+
isDesigner = false
|
|
70
|
+
}: BlockRendererProps) => {
|
|
71
|
+
const prop = (key: string, fallback: string) =>
|
|
72
|
+
String(
|
|
73
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const rawMode = prop('mode', 'login');
|
|
77
|
+
const mode = rawMode === 'register' ? 'register' : 'login';
|
|
78
|
+
const providerId = prop('providerId', 'default');
|
|
79
|
+
const callbackUrl = prop('callbackUrl', '/');
|
|
80
|
+
const submitLabel = prop(
|
|
81
|
+
'submitLabel',
|
|
82
|
+
mode === 'register' ? 'Create account' : 'Log in'
|
|
83
|
+
);
|
|
84
|
+
// Empty on purpose: an unset busy label keeps the submit caption stable while
|
|
85
|
+
// the request is in flight, which is what every published block does today.
|
|
86
|
+
const busyLabel = prop('busyLabel', '');
|
|
87
|
+
const errorLabel = prop('errorLabel', 'Authentication failed.');
|
|
88
|
+
const showLabels = readToggle(
|
|
89
|
+
block.properties?.showLabels,
|
|
90
|
+
currentBreakpoint,
|
|
91
|
+
true
|
|
92
|
+
);
|
|
93
|
+
const showForgotPassword = readToggle(
|
|
94
|
+
block.properties?.showForgotPassword,
|
|
95
|
+
currentBreakpoint,
|
|
96
|
+
false
|
|
97
|
+
);
|
|
98
|
+
const forgotPasswordLabel = prop('forgotPasswordLabel', 'Forgot password?');
|
|
99
|
+
const forgotPasswordUrl = prop('forgotPasswordUrl', '/auth/forgot-password');
|
|
100
|
+
const formType =
|
|
101
|
+
mode === 'register'
|
|
102
|
+
? prop('registerFormType', 'registration')
|
|
103
|
+
: prop('loginFormType', 'login');
|
|
104
|
+
|
|
105
|
+
const router = useRouter();
|
|
106
|
+
const { locale, defaultLocaleValue } = useLocalization();
|
|
107
|
+
|
|
108
|
+
const [values, setValues] = useState({
|
|
109
|
+
email: '',
|
|
110
|
+
password: '',
|
|
111
|
+
first_name: '',
|
|
112
|
+
last_name: ''
|
|
113
|
+
});
|
|
114
|
+
const [error, setError] = useState('');
|
|
115
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
116
|
+
|
|
117
|
+
const setField = (key: keyof typeof values) => (
|
|
118
|
+
event: React.ChangeEvent<HTMLInputElement>
|
|
119
|
+
) => {
|
|
120
|
+
const nextValue = event.target.value;
|
|
121
|
+
setValues((current) => ({ ...current, [key]: nextValue }));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const submit = async (event: React.FormEvent) => {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
setError('');
|
|
127
|
+
|
|
128
|
+
if (isDesigner) return;
|
|
129
|
+
|
|
130
|
+
const payload: Record<string, string> = {
|
|
131
|
+
formType,
|
|
132
|
+
// The core credentials provider looks the locale up in Settings and
|
|
133
|
+
// throws when it is missing, so an explicit value is always sent.
|
|
134
|
+
locale: locale || defaultLocaleValue || '',
|
|
135
|
+
email: values.email,
|
|
136
|
+
password: values.password
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (mode === 'register') {
|
|
140
|
+
payload.first_name = values.first_name;
|
|
141
|
+
payload.last_name = values.last_name;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
setIsSubmitting(true);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const response = await signIn(providerId, {
|
|
148
|
+
redirect: false,
|
|
149
|
+
callbackUrl,
|
|
150
|
+
...payload
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!response || response.error) {
|
|
154
|
+
setError(readAuthError(response?.error) || errorLabel);
|
|
155
|
+
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
router.refresh();
|
|
160
|
+
router.push(callbackUrl);
|
|
161
|
+
} catch {
|
|
162
|
+
setError(errorLabel);
|
|
163
|
+
} finally {
|
|
164
|
+
setIsSubmitting(false);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const inputStyle: React.CSSProperties = {
|
|
169
|
+
width: '100%',
|
|
170
|
+
border: '1px solid var(--auth-form-border, #d1d5db)',
|
|
171
|
+
background: 'transparent',
|
|
172
|
+
color: 'inherit',
|
|
173
|
+
padding: '10px 12px',
|
|
174
|
+
outline: 'none'
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const field = (
|
|
178
|
+
key: keyof typeof values,
|
|
179
|
+
label: string,
|
|
180
|
+
type: string,
|
|
181
|
+
autoComplete: string,
|
|
182
|
+
placeholder: string
|
|
183
|
+
) => (
|
|
184
|
+
<label
|
|
185
|
+
data-auth-form-field={key}
|
|
186
|
+
{...partAttrs('field')}
|
|
187
|
+
style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
|
|
188
|
+
>
|
|
189
|
+
{/* Hiding the caption must not cost the input its accessible name, so the
|
|
190
|
+
label text moves onto aria-label instead of disappearing. */}
|
|
191
|
+
{showLabels && <span {...partAttrs('label')}>{label}</span>}
|
|
192
|
+
<input
|
|
193
|
+
type={type}
|
|
194
|
+
name={key}
|
|
195
|
+
value={values[key]}
|
|
196
|
+
onChange={setField(key)}
|
|
197
|
+
autoComplete={autoComplete}
|
|
198
|
+
placeholder={placeholder || undefined}
|
|
199
|
+
aria-label={showLabels ? undefined : label}
|
|
200
|
+
required
|
|
201
|
+
{...partAttrs('input')}
|
|
202
|
+
style={inputStyle}
|
|
203
|
+
/>
|
|
204
|
+
</label>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<form
|
|
209
|
+
data-auth-form=""
|
|
210
|
+
data-auth-form-mode={mode}
|
|
211
|
+
{...partAttrs('form')}
|
|
212
|
+
onSubmit={submit}
|
|
213
|
+
style={{
|
|
214
|
+
display: 'flex',
|
|
215
|
+
flexDirection: 'column',
|
|
216
|
+
gap: 'var(--auth-form-gap, 12px)'
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
{mode === 'register' && (
|
|
220
|
+
<>
|
|
221
|
+
{field(
|
|
222
|
+
'first_name',
|
|
223
|
+
prop('firstNameLabel', 'First name'),
|
|
224
|
+
'text',
|
|
225
|
+
'given-name',
|
|
226
|
+
prop('firstNamePlaceholder', '')
|
|
227
|
+
)}
|
|
228
|
+
{field(
|
|
229
|
+
'last_name',
|
|
230
|
+
prop('lastNameLabel', 'Last name'),
|
|
231
|
+
'text',
|
|
232
|
+
'family-name',
|
|
233
|
+
prop('lastNamePlaceholder', '')
|
|
234
|
+
)}
|
|
235
|
+
</>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{field(
|
|
239
|
+
'email',
|
|
240
|
+
prop('emailLabel', 'E-mail'),
|
|
241
|
+
'email',
|
|
242
|
+
'email',
|
|
243
|
+
prop('emailPlaceholder', '')
|
|
244
|
+
)}
|
|
245
|
+
{field(
|
|
246
|
+
'password',
|
|
247
|
+
prop('passwordLabel', 'Password'),
|
|
248
|
+
'password',
|
|
249
|
+
mode === 'register' ? 'new-password' : 'current-password',
|
|
250
|
+
prop('passwordPlaceholder', '')
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{showForgotPassword && forgotPasswordLabel && (
|
|
254
|
+
<NextLink
|
|
255
|
+
data-auth-form-link="reset-password"
|
|
256
|
+
{...partAttrs('link')}
|
|
257
|
+
href={isDesigner ? '#' : forgotPasswordUrl}
|
|
258
|
+
onClick={(event) => {
|
|
259
|
+
if (isDesigner) event.preventDefault();
|
|
260
|
+
}}
|
|
261
|
+
style={{ color: 'inherit' }}
|
|
262
|
+
>
|
|
263
|
+
{forgotPasswordLabel}
|
|
264
|
+
</NextLink>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{error && (
|
|
268
|
+
<div data-auth-form-error="" {...partAttrs('error')} role="alert">
|
|
269
|
+
{error}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
<button
|
|
274
|
+
type="submit"
|
|
275
|
+
data-auth-form-submit=""
|
|
276
|
+
{...partAttrs('submit', {
|
|
277
|
+
loading: isSubmitting,
|
|
278
|
+
disabled: isSubmitting
|
|
279
|
+
})}
|
|
280
|
+
disabled={isSubmitting}
|
|
281
|
+
style={{
|
|
282
|
+
padding: '12px 20px',
|
|
283
|
+
background: 'var(--auth-form-cta-bg, #111827)',
|
|
284
|
+
color: 'var(--auth-form-cta-color, #ffffff)',
|
|
285
|
+
border: 'none',
|
|
286
|
+
cursor: 'pointer'
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
{isSubmitting && busyLabel ? busyLabel : submitLabel}
|
|
290
|
+
</button>
|
|
291
|
+
</form>
|
|
292
|
+
);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export default AuthFormBlock;
|