@akinon/pz-theme 2.0.37-beta.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 +4 -4
  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,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;