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