@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,362 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { signIn } from 'next-auth/react';
|
|
6
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
7
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
8
|
+
import { readToggle } from './product-block-utils';
|
|
9
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* pz brands serialise an AuthError[] into next-auth's `error` string, but a
|
|
13
|
+
* plain string is also possible, so both shapes are handled. Kept local rather
|
|
14
|
+
* than imported from auth-form-block: that block owns its own copy and making
|
|
15
|
+
* it an export would change a shipped module's public surface.
|
|
16
|
+
*/
|
|
17
|
+
const readAuthError = (
|
|
18
|
+
error: string | null | undefined,
|
|
19
|
+
otpMessage: string
|
|
20
|
+
): string => {
|
|
21
|
+
if (!error) return '';
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(error);
|
|
25
|
+
if (!Array.isArray(parsed)) return String(error);
|
|
26
|
+
|
|
27
|
+
const messages: string[] = [];
|
|
28
|
+
|
|
29
|
+
parsed.forEach((entry) => {
|
|
30
|
+
const type = (entry as { type?: unknown })?.type;
|
|
31
|
+
const data = (entry as { data?: unknown })?.data;
|
|
32
|
+
|
|
33
|
+
// The OTP branch carries no message: superstep answers a valid
|
|
34
|
+
// registration with an `otp` error and expects the storefront to open an
|
|
35
|
+
// SMS-verification modal. This block has no modal, so the shopper is at
|
|
36
|
+
// least told what happened instead of seeing a silent failure.
|
|
37
|
+
if (type === 'otp') {
|
|
38
|
+
messages.push(otpMessage);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (Array.isArray(data)) {
|
|
43
|
+
data.forEach((item) => {
|
|
44
|
+
if (typeof item === 'string') {
|
|
45
|
+
messages.push(item);
|
|
46
|
+
} else if (item && typeof item === 'object') {
|
|
47
|
+
const value = (item as { value?: unknown }).value;
|
|
48
|
+
if (typeof value === 'string') messages.push(value);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
} else if (typeof data === 'string') {
|
|
52
|
+
messages.push(data);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return messages.join(' ') || String(error);
|
|
57
|
+
} catch {
|
|
58
|
+
return String(error);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The commerce registration endpoint is reached through next-auth's credentials
|
|
64
|
+
* provider, exactly like the brand's own register view: the provider id is
|
|
65
|
+
* `default` and the url segment is selected by `formType`.
|
|
66
|
+
*/
|
|
67
|
+
const PROVIDER_ID = 'default';
|
|
68
|
+
const FORM_TYPE = 'registration';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Functional auth block: full registration form (name, surname, e-mail,
|
|
72
|
+
* password, optional phone, consents) submitted through the v1 register path.
|
|
73
|
+
* Behaviour is fixed (commerce sign-up); the editor owns the labels, the phone
|
|
74
|
+
* field and the consent wording.
|
|
75
|
+
* Properties: submitLabel, busyLabel, showPhone, showLabels, firstNameLabel,
|
|
76
|
+
* lastNameLabel, emailLabel, passwordLabel, phoneLabel, the matching
|
|
77
|
+
* *Placeholder keys, consentText, marketingConsentText, showMarketingConsent,
|
|
78
|
+
* consentRequiredLabel, errorLabel, otpLabel.
|
|
79
|
+
* Parts: form, field, label, input, consent, checkbox, consent-text, error,
|
|
80
|
+
* submit.
|
|
81
|
+
*/
|
|
82
|
+
const RegisterFormBlock = ({
|
|
83
|
+
block,
|
|
84
|
+
currentBreakpoint = 'desktop',
|
|
85
|
+
isDesigner = false
|
|
86
|
+
}: BlockRendererProps) => {
|
|
87
|
+
const prop = (key: string, fallback: string) =>
|
|
88
|
+
String(
|
|
89
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const submitLabel = prop('submitLabel', 'Create account');
|
|
93
|
+
// Empty on purpose: without a busy label the submit caption stays put while
|
|
94
|
+
// the request runs, which is what published blocks render today.
|
|
95
|
+
const busyLabel = prop('busyLabel', '');
|
|
96
|
+
const showPhone = readToggle(block.properties?.showPhone, currentBreakpoint, true);
|
|
97
|
+
const showLabels = readToggle(
|
|
98
|
+
block.properties?.showLabels,
|
|
99
|
+
currentBreakpoint,
|
|
100
|
+
true
|
|
101
|
+
);
|
|
102
|
+
const showMarketingConsent = readToggle(
|
|
103
|
+
block.properties?.showMarketingConsent,
|
|
104
|
+
currentBreakpoint,
|
|
105
|
+
true
|
|
106
|
+
);
|
|
107
|
+
const consentText = prop(
|
|
108
|
+
'consentText',
|
|
109
|
+
'I have read and accept the membership agreement and the privacy notice.'
|
|
110
|
+
);
|
|
111
|
+
const marketingConsentText = prop(
|
|
112
|
+
'marketingConsentText',
|
|
113
|
+
'I would like to receive campaign and marketing messages.'
|
|
114
|
+
);
|
|
115
|
+
const consentRequiredLabel = prop(
|
|
116
|
+
'consentRequiredLabel',
|
|
117
|
+
'Please accept the membership agreement to continue.'
|
|
118
|
+
);
|
|
119
|
+
const errorLabel = prop('errorLabel', 'Registration failed.');
|
|
120
|
+
const otpLabel = prop(
|
|
121
|
+
'otpLabel',
|
|
122
|
+
'A verification code was sent. Please confirm it to finish.'
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const router = useRouter();
|
|
126
|
+
const { locale, defaultLocaleValue } = useLocalization();
|
|
127
|
+
|
|
128
|
+
const [values, setValues] = useState({
|
|
129
|
+
first_name: '',
|
|
130
|
+
last_name: '',
|
|
131
|
+
email: '',
|
|
132
|
+
password: '',
|
|
133
|
+
phone: ''
|
|
134
|
+
});
|
|
135
|
+
const [legalConsent, setLegalConsent] = useState(false);
|
|
136
|
+
const [marketingConsent, setMarketingConsent] = useState(false);
|
|
137
|
+
const [error, setError] = useState('');
|
|
138
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
139
|
+
|
|
140
|
+
const setField =
|
|
141
|
+
(key: keyof typeof values) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
142
|
+
const nextValue = event.target.value;
|
|
143
|
+
setValues((current) => ({ ...current, [key]: nextValue }));
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const submit = async (event: React.FormEvent) => {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
setError('');
|
|
149
|
+
|
|
150
|
+
// Submitting inside the designer would create a real commerce account.
|
|
151
|
+
if (isDesigner) return;
|
|
152
|
+
|
|
153
|
+
if (!legalConsent) {
|
|
154
|
+
setError(consentRequiredLabel);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const payload: Record<string, string | boolean> = {
|
|
159
|
+
formType: FORM_TYPE,
|
|
160
|
+
// The credentials provider looks the locale up in Settings and throws
|
|
161
|
+
// when it is missing, so an explicit value is always sent.
|
|
162
|
+
locale: locale || defaultLocaleValue || '',
|
|
163
|
+
first_name: values.first_name,
|
|
164
|
+
last_name: values.last_name,
|
|
165
|
+
email: values.email,
|
|
166
|
+
password: values.password,
|
|
167
|
+
// Commerce requires both consent flags; one checkbox gates them because
|
|
168
|
+
// both are mandatory to register at all.
|
|
169
|
+
is_allowed: true,
|
|
170
|
+
confirm: true,
|
|
171
|
+
email_allowed: marketingConsent,
|
|
172
|
+
sms_allowed: marketingConsent,
|
|
173
|
+
call_allowed: marketingConsent,
|
|
174
|
+
whatsapp_allowed: marketingConsent
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (showPhone) {
|
|
178
|
+
// Commerce expects bare digits; the mask characters brands render around
|
|
179
|
+
// the phone input would fail its length validation.
|
|
180
|
+
payload.phone = values.phone.replace(/[^\d]/g, '');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// `permissions` is only meaningful when opted in — the brand deletes the
|
|
184
|
+
// key entirely rather than sending a falsy value.
|
|
185
|
+
if (marketingConsent) payload.permissions = 'on';
|
|
186
|
+
|
|
187
|
+
setIsSubmitting(true);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const response = await signIn(PROVIDER_ID, {
|
|
191
|
+
redirect: false,
|
|
192
|
+
callbackUrl: '/',
|
|
193
|
+
...payload
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!response || response.error) {
|
|
197
|
+
setError(readAuthError(response?.error, otpLabel) || errorLabel);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
router.refresh();
|
|
202
|
+
router.push(response.url || '/');
|
|
203
|
+
} catch {
|
|
204
|
+
setError(errorLabel);
|
|
205
|
+
} finally {
|
|
206
|
+
setIsSubmitting(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const inputStyle: React.CSSProperties = {
|
|
211
|
+
width: '100%',
|
|
212
|
+
border: '1px solid var(--register-form-border, #d1d5db)',
|
|
213
|
+
background: 'transparent',
|
|
214
|
+
color: 'inherit',
|
|
215
|
+
padding: '10px 12px',
|
|
216
|
+
outline: 'none'
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const field = (
|
|
220
|
+
key: keyof typeof values,
|
|
221
|
+
label: string,
|
|
222
|
+
type: string,
|
|
223
|
+
autoComplete: string,
|
|
224
|
+
placeholder: string
|
|
225
|
+
) => (
|
|
226
|
+
<label
|
|
227
|
+
data-register-form-field={key}
|
|
228
|
+
{...partAttrs('field')}
|
|
229
|
+
style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
|
|
230
|
+
>
|
|
231
|
+
{/* Hiding the caption must not cost the input its accessible name, so the
|
|
232
|
+
label text moves onto aria-label instead of disappearing. */}
|
|
233
|
+
{showLabels && <span {...partAttrs('label')}>{label}</span>}
|
|
234
|
+
<input
|
|
235
|
+
type={type}
|
|
236
|
+
name={key}
|
|
237
|
+
value={values[key]}
|
|
238
|
+
onChange={setField(key)}
|
|
239
|
+
autoComplete={autoComplete}
|
|
240
|
+
placeholder={placeholder || undefined}
|
|
241
|
+
aria-label={showLabels ? undefined : label}
|
|
242
|
+
required
|
|
243
|
+
{...partAttrs('input')}
|
|
244
|
+
style={inputStyle}
|
|
245
|
+
/>
|
|
246
|
+
</label>
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const consent = (
|
|
250
|
+
checked: boolean,
|
|
251
|
+
onChange: (next: boolean) => void,
|
|
252
|
+
label: string,
|
|
253
|
+
name: string,
|
|
254
|
+
required: boolean
|
|
255
|
+
) => (
|
|
256
|
+
<label
|
|
257
|
+
data-register-form-consent={name}
|
|
258
|
+
{...partAttrs('consent')}
|
|
259
|
+
style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}
|
|
260
|
+
>
|
|
261
|
+
<input
|
|
262
|
+
type="checkbox"
|
|
263
|
+
name={name}
|
|
264
|
+
checked={checked}
|
|
265
|
+
onChange={(event) => onChange(event.target.checked)}
|
|
266
|
+
required={required}
|
|
267
|
+
{...partAttrs('checkbox', { checked })}
|
|
268
|
+
style={{ marginTop: '3px' }}
|
|
269
|
+
/>
|
|
270
|
+
<span {...partAttrs('consent-text')}>{label}</span>
|
|
271
|
+
</label>
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<form
|
|
276
|
+
data-register-form=""
|
|
277
|
+
{...partAttrs('form')}
|
|
278
|
+
onSubmit={submit}
|
|
279
|
+
style={{
|
|
280
|
+
display: 'flex',
|
|
281
|
+
flexDirection: 'column',
|
|
282
|
+
gap: 'var(--register-form-gap, 12px)'
|
|
283
|
+
}}
|
|
284
|
+
>
|
|
285
|
+
{field(
|
|
286
|
+
'first_name',
|
|
287
|
+
prop('firstNameLabel', 'First name'),
|
|
288
|
+
'text',
|
|
289
|
+
'given-name',
|
|
290
|
+
prop('firstNamePlaceholder', '')
|
|
291
|
+
)}
|
|
292
|
+
{field(
|
|
293
|
+
'last_name',
|
|
294
|
+
prop('lastNameLabel', 'Last name'),
|
|
295
|
+
'text',
|
|
296
|
+
'family-name',
|
|
297
|
+
prop('lastNamePlaceholder', '')
|
|
298
|
+
)}
|
|
299
|
+
{field(
|
|
300
|
+
'email',
|
|
301
|
+
prop('emailLabel', 'E-mail'),
|
|
302
|
+
'email',
|
|
303
|
+
'email',
|
|
304
|
+
prop('emailPlaceholder', '')
|
|
305
|
+
)}
|
|
306
|
+
{field(
|
|
307
|
+
'password',
|
|
308
|
+
prop('passwordLabel', 'Password'),
|
|
309
|
+
'password',
|
|
310
|
+
'new-password',
|
|
311
|
+
prop('passwordPlaceholder', '')
|
|
312
|
+
)}
|
|
313
|
+
{showPhone &&
|
|
314
|
+
field(
|
|
315
|
+
'phone',
|
|
316
|
+
prop('phoneLabel', 'Phone'),
|
|
317
|
+
'tel',
|
|
318
|
+
'tel',
|
|
319
|
+
prop('phonePlaceholder', '')
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
{consent(legalConsent, setLegalConsent, consentText, 'is_allowed', true)}
|
|
323
|
+
{showMarketingConsent &&
|
|
324
|
+
consent(
|
|
325
|
+
marketingConsent,
|
|
326
|
+
setMarketingConsent,
|
|
327
|
+
marketingConsentText,
|
|
328
|
+
'permissions',
|
|
329
|
+
false
|
|
330
|
+
)}
|
|
331
|
+
|
|
332
|
+
{error && (
|
|
333
|
+
<div data-register-form-error="" {...partAttrs('error')} role="alert">
|
|
334
|
+
{error}
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
<button
|
|
339
|
+
type="submit"
|
|
340
|
+
data-register-form-submit=""
|
|
341
|
+
{...partAttrs('submit', {
|
|
342
|
+
loading: isSubmitting,
|
|
343
|
+
disabled: isSubmitting
|
|
344
|
+
})}
|
|
345
|
+
disabled={isSubmitting}
|
|
346
|
+
aria-busy={isSubmitting}
|
|
347
|
+
style={{
|
|
348
|
+
padding: '12px 20px',
|
|
349
|
+
background: 'var(--register-form-cta-bg, #111827)',
|
|
350
|
+
color: 'var(--register-form-cta-color, #ffffff)',
|
|
351
|
+
border: 'none',
|
|
352
|
+
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
|
353
|
+
opacity: isSubmitting ? 0.7 : 1
|
|
354
|
+
}}
|
|
355
|
+
>
|
|
356
|
+
{isSubmitting && busyLabel ? busyLabel : submitLabel}
|
|
357
|
+
</button>
|
|
358
|
+
</form>
|
|
359
|
+
);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
export default RegisterFormBlock;
|