@akinon/pz-theme 2.0.95-rc.0 → 2.0.95
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 +8 -33
- package/package.json +4 -4
- package/src/__tests__/auth-captcha.test.tsx +261 -0
- package/src/__tests__/auth-commerce-events.test.tsx +5 -1
- package/src/__tests__/auth-destination.test.tsx +348 -0
- package/src/__tests__/auth-error-reading.test.ts +232 -0
- package/src/__tests__/auth-flow-scope.test.tsx +162 -0
- package/src/__tests__/auth-form-error-block.test.tsx +2 -0
- package/src/__tests__/auth-form-success-block.test.tsx +429 -0
- package/src/__tests__/auth-form-validation.test.tsx +2 -0
- package/src/__tests__/auth-slot-coverage.test.ts +9 -1
- package/src/__tests__/auth-step-title.test.tsx +2 -0
- package/src/__tests__/block-action-audience.test.ts +154 -0
- package/src/__tests__/detached-submit-state.test.tsx +80 -2
- package/src/__tests__/forgot-password-composed.test.tsx +2 -0
- package/src/__tests__/form-state-composition.test.tsx +484 -0
- package/src/__tests__/modal-login-close.test.tsx +366 -0
- package/src/__tests__/otp-form-composed.test.tsx +2 -0
- package/src/__tests__/password-reset-step.test.tsx +2 -0
- package/src/__tests__/register-form-composed.test.tsx +253 -4
- package/src/__tests__/registry-parity.test.ts +12 -0
- package/src/__tests__/reset-password-composed.test.tsx +385 -0
- package/src/auth/auth-captcha.tsx +125 -0
- package/src/auth/auth-destination.ts +168 -0
- package/src/auth/auth-field-errors.tsx +31 -0
- package/src/auth/form-state-context.tsx +170 -0
- package/src/auth/form-success-context.tsx +56 -0
- package/src/auth/read-auth-error.ts +332 -0
- package/src/blocks/__tests__/slider-block.test.tsx +265 -1
- package/src/blocks/account-button-block.tsx +25 -0
- package/src/blocks/add-to-cart-block.tsx +15 -0
- package/src/blocks/auth-field-block.tsx +13 -3
- package/src/blocks/auth-flow-block.tsx +45 -3
- package/src/blocks/auth-form-block.tsx +337 -54
- package/src/blocks/auth-success-block.tsx +85 -0
- package/src/blocks/block-renderer-registry.tsx +8 -0
- package/src/blocks/button-block.tsx +43 -13
- package/src/blocks/captcha-block.tsx +61 -0
- package/src/blocks/change-password-form-block.tsx +15 -0
- package/src/blocks/forgot-password-block.tsx +132 -9
- package/src/blocks/form-state-block.tsx +84 -0
- package/src/blocks/newsletter-form-block.tsx +16 -0
- package/src/blocks/otp-form-block.tsx +161 -67
- package/src/blocks/profile-form-block.tsx +13 -0
- package/src/blocks/register-form-block.tsx +362 -110
- package/src/blocks/reset-password-block.tsx +717 -0
- package/src/blocks/slider-block.tsx +170 -4
- package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
- package/src/chrome/placeholder-slugs.ts +3 -0
- package/src/chrome/route-slug.ts +11 -0
- package/src/index.ts +10 -0
- package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
- package/src/overlays/__tests__/modal-registry.test.ts +5 -1
- package/src/overlays/modal-chrome-css.ts +8 -1
- package/src/overlays/modal-host-context.tsx +66 -0
- package/src/overlays/modal-preview-host.tsx +7 -2
- package/src/overlays/modal-registry.ts +38 -1
- package/src/overlays/overlay-loading.tsx +144 -0
- package/src/overlays/overlay-surface.tsx +23 -0
- package/src/overlays/overlay-tokens.ts +12 -0
- package/src/overlays/theme-modal-mount.tsx +85 -6
- package/src/utils/visibility-rules.ts +114 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a commerce refusal actually says, and which field it says it about.
|
|
3
|
+
*
|
|
4
|
+
* Every composed auth form used to carry its own copy of this, and all three
|
|
5
|
+
* copies made the same mistake: they kept a message only when it was a string.
|
|
6
|
+
* Commerce sends field errors as `{ name, value: string[] }` — that is the
|
|
7
|
+
* shape `buildFieldErrors` in `@akinon/next/api/auth.ts` serialises — so the
|
|
8
|
+
* commonest real refusals ("this e-mail is already registered", "this password
|
|
9
|
+
* is too common") extracted to nothing, the empty result fell through to
|
|
10
|
+
* `String(error)`, and the shopper was shown the raw
|
|
11
|
+
* `[{"type":"field_errors","data":[…]}]`. Because that string is non-empty, the
|
|
12
|
+
* designer's authored fallback sentence never fired either, so there was no way
|
|
13
|
+
* to author around it.
|
|
14
|
+
*
|
|
15
|
+
* Two shapes reach a form, and both land here:
|
|
16
|
+
*
|
|
17
|
+
* - the serialised `AuthError[]` string next-auth hands back (v4 on `error`,
|
|
18
|
+
* v5 on `code`), which is what a `signIn()` form reads;
|
|
19
|
+
* - the raw commerce body an RTK Query mutation rejects with — `{ email:
|
|
20
|
+
* ['…'], non_field_errors: ['…'] }` — which is what the forgot-password
|
|
21
|
+
* form gets, and used to discard entirely.
|
|
22
|
+
*
|
|
23
|
+
* The reading separates what belongs to the form from what belongs to a field,
|
|
24
|
+
* because those go to different places on screen: the form-level sentence to a
|
|
25
|
+
* Form Error block, the field-level ones under the inputs, through the same
|
|
26
|
+
* `AuthFieldErrorsProvider` client validation already writes to.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Nothing was wrong, or nothing legible was said about it. */
|
|
30
|
+
const EMPTY_READING = {
|
|
31
|
+
formMessage: '',
|
|
32
|
+
fieldMessages: {} as Readonly<Record<string, string>>,
|
|
33
|
+
hasOtpChallenge: false,
|
|
34
|
+
hasCaptchaChallenge: false
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface AuthErrorReading {
|
|
38
|
+
/** The sentence for the form-level slot. '' when every message found a field. */
|
|
39
|
+
formMessage: string;
|
|
40
|
+
/** One sentence per submitted control name, ready for the errors provider. */
|
|
41
|
+
fieldMessages: Readonly<Record<string, string>>;
|
|
42
|
+
/**
|
|
43
|
+
* Commerce accepted the credentials and wants an SMS code. A HANDOFF, not a
|
|
44
|
+
* failure — the register form parks its payload and an `otp-form` finishes
|
|
45
|
+
* the journey — which is why it is reported separately from the messages.
|
|
46
|
+
*/
|
|
47
|
+
hasOtpChallenge: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Commerce escalated to a captcha after too many attempts. Also carries no
|
|
50
|
+
* message of its own, and nothing in this theme can answer it yet, so a form
|
|
51
|
+
* reports it as a sentence rather than pretending the submit merely failed.
|
|
52
|
+
*/
|
|
53
|
+
hasCaptchaChallenge: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Copy for the two challenges that arrive with no words in them. */
|
|
57
|
+
export interface AuthErrorCopy {
|
|
58
|
+
otp?: string;
|
|
59
|
+
captcha?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every sentence inside one value, whatever shape it arrived in.
|
|
64
|
+
*
|
|
65
|
+
* A commerce field error is `string[]`; a `non_field_errors` payload is
|
|
66
|
+
* sometimes a bare string; a brand's middleware occasionally re-encodes the
|
|
67
|
+
* array as a JSON string. All three are the same thing to a shopper, so all
|
|
68
|
+
* three are flattened here rather than in three call sites.
|
|
69
|
+
*/
|
|
70
|
+
const messagesOf = (value: unknown): string[] => {
|
|
71
|
+
if (typeof value === 'string') {
|
|
72
|
+
const text = value.trim();
|
|
73
|
+
if (!text) return [];
|
|
74
|
+
|
|
75
|
+
// A JSON-encoded array is unwrapped, but only when it really is one:
|
|
76
|
+
// `JSON.parse` would also happily turn "42" into a number and an ordinary
|
|
77
|
+
// sentence into a throw.
|
|
78
|
+
if (text.startsWith('[') || text.startsWith('{')) {
|
|
79
|
+
try {
|
|
80
|
+
return messagesOf(JSON.parse(text));
|
|
81
|
+
} catch {
|
|
82
|
+
return [text];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return [text];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (Array.isArray(value)) {
|
|
90
|
+
return value.flatMap((item) => messagesOf(item));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (value && typeof value === 'object') {
|
|
94
|
+
const record = value as Record<string, unknown>;
|
|
95
|
+
|
|
96
|
+
// `{ name, value }` is the field-error entry; `{ message }` and
|
|
97
|
+
// `{ detail }` are what a brand's own error middleware tends to send.
|
|
98
|
+
return messagesOf(record.value ?? record.message ?? record.detail);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return [];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** A field's messages, appended rather than replacing what is already there. */
|
|
105
|
+
const addFieldMessage = (
|
|
106
|
+
fieldMessages: Record<string, string>,
|
|
107
|
+
name: string,
|
|
108
|
+
messages: string[]
|
|
109
|
+
) => {
|
|
110
|
+
const text = messages.join(' ');
|
|
111
|
+
if (!text) return;
|
|
112
|
+
|
|
113
|
+
fieldMessages[name] = fieldMessages[name]
|
|
114
|
+
? `${fieldMessages[name]} ${text}`
|
|
115
|
+
: text;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The serialised `AuthError[]` a next-auth credentials refusal carries.
|
|
120
|
+
*
|
|
121
|
+
* An unknown entry type keeps its messages at form level rather than being
|
|
122
|
+
* dropped: a brand that added its own error type must still be able to tell
|
|
123
|
+
* the shopper something, and that is what every previous copy of this did.
|
|
124
|
+
*/
|
|
125
|
+
export const readAuthError = (
|
|
126
|
+
error: string | null | undefined,
|
|
127
|
+
copy: AuthErrorCopy = {}
|
|
128
|
+
): AuthErrorReading => {
|
|
129
|
+
if (!error) return EMPTY_READING;
|
|
130
|
+
|
|
131
|
+
let parsed: unknown;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(error);
|
|
135
|
+
} catch {
|
|
136
|
+
return { ...EMPTY_READING, formMessage: String(error) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!Array.isArray(parsed)) {
|
|
140
|
+
return { ...EMPTY_READING, formMessage: String(error) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const formMessages: string[] = [];
|
|
144
|
+
const fieldMessages: Record<string, string> = {};
|
|
145
|
+
let hasOtpChallenge = false;
|
|
146
|
+
let hasCaptchaChallenge = false;
|
|
147
|
+
|
|
148
|
+
parsed.forEach((entry) => {
|
|
149
|
+
const type = (entry as { type?: unknown })?.type;
|
|
150
|
+
const data = (entry as { data?: unknown })?.data;
|
|
151
|
+
|
|
152
|
+
if (type === 'otp') {
|
|
153
|
+
hasOtpChallenge = true;
|
|
154
|
+
if (copy.otp) formMessages.push(copy.otp);
|
|
155
|
+
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (type === 'captcha') {
|
|
160
|
+
hasCaptchaChallenge = true;
|
|
161
|
+
if (copy.captcha) formMessages.push(copy.captcha);
|
|
162
|
+
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (type === 'field_errors' && Array.isArray(data)) {
|
|
167
|
+
data.forEach((item) => {
|
|
168
|
+
const name = (item as { name?: unknown })?.name;
|
|
169
|
+
const messages = messagesOf((item as { value?: unknown })?.value);
|
|
170
|
+
|
|
171
|
+
// `non_field_errors` is filtered out upstream, but a brand's own
|
|
172
|
+
// serialiser may still put it in the list — it is not a field, so it
|
|
173
|
+
// must not be hung under one.
|
|
174
|
+
if (typeof name !== 'string' || !name || name === 'non_field_errors') {
|
|
175
|
+
formMessages.push(...messages);
|
|
176
|
+
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
addFieldMessage(fieldMessages, name, messages);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
formMessages.push(...messagesOf(data));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const formMessage = formMessages.join(' ');
|
|
190
|
+
|
|
191
|
+
// Nothing legible AND nothing attributed: show the raw detail rather than an
|
|
192
|
+
// empty error slot, which is what the copies this replaces did. A challenge
|
|
193
|
+
// is not "nothing" — the form has its own answer for those.
|
|
194
|
+
if (
|
|
195
|
+
!formMessage &&
|
|
196
|
+
Object.keys(fieldMessages).length === 0 &&
|
|
197
|
+
!hasOtpChallenge &&
|
|
198
|
+
!hasCaptchaChallenge
|
|
199
|
+
) {
|
|
200
|
+
return { ...EMPTY_READING, formMessage: String(error) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { formMessage, fieldMessages, hasOtpChallenge, hasCaptchaChallenge };
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The raw commerce body an RTK Query mutation rejects with — `error.data`.
|
|
208
|
+
*
|
|
209
|
+
* Same reading, different envelope: keys are field names, `non_field_errors`
|
|
210
|
+
* and `detail` are form-level, and a `redirect_url` naming captcha is the same
|
|
211
|
+
* escalation the credentials provider reports as `{ type: 'captcha' }` (see
|
|
212
|
+
* `buildFieldErrors`).
|
|
213
|
+
*/
|
|
214
|
+
export const readCommerceError = (
|
|
215
|
+
payload: unknown,
|
|
216
|
+
copy: AuthErrorCopy = {}
|
|
217
|
+
): AuthErrorReading => {
|
|
218
|
+
if (!payload) return EMPTY_READING;
|
|
219
|
+
|
|
220
|
+
if (typeof payload === 'string') return readAuthError(payload, copy);
|
|
221
|
+
|
|
222
|
+
if (typeof payload !== 'object' || Array.isArray(payload)) {
|
|
223
|
+
return { ...EMPTY_READING, formMessage: messagesOf(payload).join(' ') };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const record = payload as Record<string, unknown>;
|
|
227
|
+
const formMessages: string[] = [];
|
|
228
|
+
const fieldMessages: Record<string, string> = {};
|
|
229
|
+
let hasCaptchaChallenge = false;
|
|
230
|
+
|
|
231
|
+
Object.entries(record).forEach(([name, value]) => {
|
|
232
|
+
if (name === 'redirect_url') {
|
|
233
|
+
if (typeof value === 'string' && value.includes('captcha')) {
|
|
234
|
+
hasCaptchaChallenge = true;
|
|
235
|
+
if (copy.captcha) formMessages.push(copy.captcha);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (name === 'non_field_errors' || name === 'detail' || name === 'error') {
|
|
242
|
+
formMessages.push(...messagesOf(value));
|
|
243
|
+
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
addFieldMessage(fieldMessages, name, messagesOf(value));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
formMessage: formMessages.join(' '),
|
|
252
|
+
fieldMessages,
|
|
253
|
+
hasOtpChallenge: false,
|
|
254
|
+
hasCaptchaChallenge
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The reading, re-split against the form that is actually on screen.
|
|
260
|
+
*
|
|
261
|
+
* A message hung on a name no control submits under would be a message the
|
|
262
|
+
* shopper never sees — commerce names a field this composition did not author
|
|
263
|
+
* (a brand-specific `tckn`, a field the designer removed), and the sentence
|
|
264
|
+
* would vanish into a provider nothing reads. Those move up to the form-level
|
|
265
|
+
* slot, which is the one place that always exists.
|
|
266
|
+
*
|
|
267
|
+
* Called with no form (the fixed-row path measures nothing) everything stays
|
|
268
|
+
* where the reading put it: those rows render `fieldErrors[name]` for their own
|
|
269
|
+
* fixed names, so the attribution is already right.
|
|
270
|
+
*/
|
|
271
|
+
export const splitByKnownFields = (
|
|
272
|
+
reading: AuthErrorReading,
|
|
273
|
+
form: HTMLFormElement | null | undefined,
|
|
274
|
+
knownNames?: readonly string[]
|
|
275
|
+
): AuthErrorReading => {
|
|
276
|
+
const names = Object.keys(reading.fieldMessages);
|
|
277
|
+
if (names.length === 0) return reading;
|
|
278
|
+
|
|
279
|
+
const known = new Set<string>(knownNames || []);
|
|
280
|
+
|
|
281
|
+
if (form) {
|
|
282
|
+
form
|
|
283
|
+
.querySelectorAll<HTMLElement>('input[name], select[name], textarea[name]')
|
|
284
|
+
.forEach((control) => {
|
|
285
|
+
const name = control.getAttribute('name');
|
|
286
|
+
if (name) known.add(name);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (known.size === 0) return reading;
|
|
291
|
+
|
|
292
|
+
const kept: Record<string, string> = {};
|
|
293
|
+
const orphaned: string[] = [];
|
|
294
|
+
|
|
295
|
+
names.forEach((name) => {
|
|
296
|
+
if (known.has(name)) {
|
|
297
|
+
kept[name] = reading.fieldMessages[name];
|
|
298
|
+
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
orphaned.push(reading.fieldMessages[name]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
if (orphaned.length === 0) return reading;
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
...reading,
|
|
309
|
+
formMessage: [reading.formMessage, ...orphaned]
|
|
310
|
+
.filter(Boolean)
|
|
311
|
+
.join(' ')
|
|
312
|
+
.trim(),
|
|
313
|
+
fieldMessages: kept
|
|
314
|
+
};
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* What a form shows in its form-level slot after a refusal.
|
|
319
|
+
*
|
|
320
|
+
* The authored fallback is for a refusal nobody could read — NOT for one whose
|
|
321
|
+
* every sentence is already under a field, where a second generic line above
|
|
322
|
+
* the inputs would only add noise.
|
|
323
|
+
*/
|
|
324
|
+
export const authFormMessage = (
|
|
325
|
+
reading: AuthErrorReading,
|
|
326
|
+
fallback: string
|
|
327
|
+
): string => {
|
|
328
|
+
if (reading.formMessage) return reading.formMessage;
|
|
329
|
+
if (Object.keys(reading.fieldMessages).length > 0) return '';
|
|
330
|
+
|
|
331
|
+
return fallback;
|
|
332
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { render } from '@testing-library/react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
3
|
|
|
4
4
|
// react-multi-carousel is an ESM/DOM-heavy dependency. Replace its default
|
|
5
5
|
// export with a passthrough that simply renders its children so we can assert
|
|
@@ -365,3 +365,267 @@ describe('SliderBlock — arrow button placement', () => {
|
|
|
365
365
|
expect(previous(container).className).toContain('z-10');
|
|
366
366
|
});
|
|
367
367
|
});
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* EVERY SLIDE IS THE SAME HEIGHT, OR THE DIVIDER BETWEEN TWO CARDS IS RAGGED.
|
|
371
|
+
*
|
|
372
|
+
* react-multi-carousel stretches its own `li` to the tallest slide, and a card
|
|
373
|
+
* inside one asks for `height: 100%`. Between the two sits this block's own
|
|
374
|
+
* per-slide wrapper, which had no height of its own — so the percentage
|
|
375
|
+
* resolved against an auto-height box, fell back to `auto`, and each card
|
|
376
|
+
* ended up as tall as its own text. The only lever a composition had was
|
|
377
|
+
* scoped custom CSS reaching into the library's class names, per section.
|
|
378
|
+
*
|
|
379
|
+
* Naming the wrapper makes it addressable from the panel (`slide/height`), so
|
|
380
|
+
* the marker is the contract: the editor's descriptor-conformance test fails
|
|
381
|
+
* the "Slide" part group the moment this attribute stops being emitted.
|
|
382
|
+
*/
|
|
383
|
+
describe('SliderBlock — the slide wrapper is a styleable part', () => {
|
|
384
|
+
const slideWrappers = (container: HTMLElement) =>
|
|
385
|
+
Array.from(container.querySelectorAll('[data-part="slide"]'));
|
|
386
|
+
|
|
387
|
+
it('marks one slide wrapper per slide', () => {
|
|
388
|
+
const { container } = render(
|
|
389
|
+
<SliderBlock
|
|
390
|
+
{...props({
|
|
391
|
+
block: {
|
|
392
|
+
...props().block,
|
|
393
|
+
blocks: [child('a'), child('b'), child('c')]
|
|
394
|
+
}
|
|
395
|
+
})}
|
|
396
|
+
/>
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
expect(slideWrappers(container)).toHaveLength(3);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("wraps each slide's card in its own marked box", () => {
|
|
403
|
+
const { container } = render(
|
|
404
|
+
<SliderBlock
|
|
405
|
+
{...props({
|
|
406
|
+
block: { ...props().block, blocks: [child('a'), child('b')] }
|
|
407
|
+
})}
|
|
408
|
+
/>
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
expect(
|
|
412
|
+
slideWrappers(container).map((wrapper) =>
|
|
413
|
+
wrapper.querySelector('[data-testid="tb"]')?.getAttribute('data-id')
|
|
414
|
+
)
|
|
415
|
+
).toEqual(['a', 'b']);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('does not mark the arrow icons, which are not slides', () => {
|
|
419
|
+
const { container } = render(
|
|
420
|
+
<SliderBlock
|
|
421
|
+
{...props({
|
|
422
|
+
block: {
|
|
423
|
+
...props().block,
|
|
424
|
+
blocks: [
|
|
425
|
+
child('left', { type: 'icon', label: 'Left Arrow Icon' }),
|
|
426
|
+
child('right', { type: 'icon', label: 'Right Arrow Icon' }),
|
|
427
|
+
child('a')
|
|
428
|
+
]
|
|
429
|
+
}
|
|
430
|
+
})}
|
|
431
|
+
/>
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
expect(slideWrappers(container)).toHaveLength(1);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('keeps the authored slide padding on the marked box', () => {
|
|
438
|
+
const { container } = render(
|
|
439
|
+
<SliderBlock
|
|
440
|
+
{...props({
|
|
441
|
+
block: {
|
|
442
|
+
...props().block,
|
|
443
|
+
properties: { 'slide-gap': 24 },
|
|
444
|
+
blocks: [child('a'), child('b')]
|
|
445
|
+
}
|
|
446
|
+
})}
|
|
447
|
+
/>
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
expect(
|
|
451
|
+
(slideWrappers(container)[0] as HTMLElement).style.paddingLeft
|
|
452
|
+
).toBe('12px');
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* TWO THINGS THE PANEL COULD ASK FOR AND THE ARROWS COULD NOT DO.
|
|
458
|
+
*
|
|
459
|
+
* The arrow buttons are not composed blocks — the slider draws its own
|
|
460
|
+
* <button> and reads a fixed handful of the arrow icon block's style keys — so
|
|
461
|
+
* a key the reader does not know reaches the theme JSON and stops there. Rotate
|
|
462
|
+
* was one of those: the same right-pointing SVG ships for both sides, and a
|
|
463
|
+
* designer turning the left one round got nothing.
|
|
464
|
+
*
|
|
465
|
+
* The reveal-on-hover option is state rather than a `group-hover:` class
|
|
466
|
+
* because a brand's Tailwind `content` list does not scan this package: a class
|
|
467
|
+
* only this file writes is never generated, so the arrows would go away and
|
|
468
|
+
* never come back.
|
|
469
|
+
*/
|
|
470
|
+
describe('SliderBlock — arrow rotate and reveal-on-hover', () => {
|
|
471
|
+
// jest/setup.ts answers every media query with `matches: false`, which is a
|
|
472
|
+
// touch device — and the option is deliberately inert there. These cases are
|
|
473
|
+
// about the pointer that CAN hover, so they say so.
|
|
474
|
+
const setHoverCapability = (matches: boolean) => {
|
|
475
|
+
window.matchMedia = ((query: string) => ({
|
|
476
|
+
matches,
|
|
477
|
+
media: query,
|
|
478
|
+
onchange: null,
|
|
479
|
+
addListener: () => {},
|
|
480
|
+
removeListener: () => {},
|
|
481
|
+
addEventListener: () => {},
|
|
482
|
+
removeEventListener: () => {},
|
|
483
|
+
dispatchEvent: () => false
|
|
484
|
+
})) as unknown as typeof window.matchMedia;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
beforeEach(() => setHoverCapability(true));
|
|
488
|
+
|
|
489
|
+
const arrow = (label: string, styles: Record<string, unknown>) =>
|
|
490
|
+
child(label, { type: 'icon', label, value: { url: '/a.svg' }, styles });
|
|
491
|
+
|
|
492
|
+
const glyphs = (container: HTMLElement) =>
|
|
493
|
+
Array.from(container.querySelectorAll('img')) as HTMLImageElement[];
|
|
494
|
+
const buttons = (container: HTMLElement) =>
|
|
495
|
+
Array.from(
|
|
496
|
+
container.querySelectorAll('button[aria-label="Previous"], button[aria-label="Next"]')
|
|
497
|
+
) as HTMLElement[];
|
|
498
|
+
|
|
499
|
+
// An authored top/bottom is what moves the arrows out of the carousel, which
|
|
500
|
+
// is the branch a storefront render can assert on (the mocked Carousel drops
|
|
501
|
+
// its customArrow props).
|
|
502
|
+
const outside = (extra: Record<string, unknown> = {}) => ({
|
|
503
|
+
top: '0px',
|
|
504
|
+
...extra
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const renderSlider = (
|
|
508
|
+
arrowStyles: Record<string, unknown>,
|
|
509
|
+
over: Record<string, unknown> = {},
|
|
510
|
+
properties: Record<string, unknown> = {}
|
|
511
|
+
) =>
|
|
512
|
+
render(
|
|
513
|
+
<SliderBlock
|
|
514
|
+
{...props({
|
|
515
|
+
block: {
|
|
516
|
+
...props().block,
|
|
517
|
+
properties,
|
|
518
|
+
blocks: [
|
|
519
|
+
arrow('Left Arrow Icon', arrowStyles),
|
|
520
|
+
child('s1'),
|
|
521
|
+
arrow('Right Arrow Icon', arrowStyles)
|
|
522
|
+
]
|
|
523
|
+
},
|
|
524
|
+
...over
|
|
525
|
+
})}
|
|
526
|
+
/>
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
it('turns an authored rotate into a rotation on the glyph', () => {
|
|
530
|
+
const { container } = renderSlider(outside({ rotate: { desktop: '180deg' } }));
|
|
531
|
+
|
|
532
|
+
expect(glyphs(container).map((img) => img.style.rotate)).toEqual([
|
|
533
|
+
'180deg',
|
|
534
|
+
'180deg'
|
|
535
|
+
]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('reads the bare number the Rotate box stores as degrees', () => {
|
|
539
|
+
const { container } = renderSlider(outside({ rotate: { desktop: 180 } }));
|
|
540
|
+
|
|
541
|
+
expect(glyphs(container)[0].style.rotate).toBe('180deg');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// The Rotate box is free text, so the function spelling turns up as often as
|
|
545
|
+
// the value one; left alone it emits `rotate: rotate(45deg)` and is dropped.
|
|
546
|
+
it('unwraps the function spelling a free-text box invites', () => {
|
|
547
|
+
const { container } = renderSlider(outside({ rotate: { desktop: 'rotate(45deg)' } }));
|
|
548
|
+
|
|
549
|
+
expect(glyphs(container)[0].style.rotate).toBe('45deg');
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('reads a leading-dot number as degrees', () => {
|
|
553
|
+
const { container } = renderSlider(outside({ rotate: { desktop: '.5' } }));
|
|
554
|
+
|
|
555
|
+
expect(glyphs(container)[0].style.rotate).toBe('.5deg');
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('passes a unit through untouched', () => {
|
|
559
|
+
const { container } = renderSlider(outside({ rotate: { desktop: '0.5turn' } }));
|
|
560
|
+
|
|
561
|
+
expect(glyphs(container)[0].style.rotate).toBe('0.5turn');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('leaves the glyph unrotated when nothing is authored', () => {
|
|
565
|
+
const { container } = renderSlider(outside());
|
|
566
|
+
|
|
567
|
+
expect(glyphs(container)[0].style.rotate).toBeFalsy();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// The rotation must not fight the button's own centring translate.
|
|
571
|
+
it('rotates the glyph, never the button', () => {
|
|
572
|
+
const { container } = renderSlider(outside({ rotate: { desktop: '180deg' } }));
|
|
573
|
+
|
|
574
|
+
expect(buttons(container)[0].style.rotate).toBeFalsy();
|
|
575
|
+
expect(buttons(container)[0].style.transform).toBeFalsy();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('hides the arrows until the slider is hovered when asked to', () => {
|
|
579
|
+
const { container } = renderSlider(outside(), {}, {
|
|
580
|
+
'arrows-on-hover': { desktop: true }
|
|
581
|
+
});
|
|
582
|
+
const root = container.firstElementChild as HTMLElement;
|
|
583
|
+
|
|
584
|
+
expect(buttons(container).map((b) => b.style.opacity)).toEqual(['0', '0']);
|
|
585
|
+
expect(buttons(container)[0].style.pointerEvents).toBe('none');
|
|
586
|
+
|
|
587
|
+
fireEvent.mouseEnter(root);
|
|
588
|
+
expect(buttons(container).map((b) => b.style.opacity)).toEqual(['1', '1']);
|
|
589
|
+
expect(buttons(container)[0].style.pointerEvents).toBe('auto');
|
|
590
|
+
|
|
591
|
+
fireEvent.mouseLeave(root);
|
|
592
|
+
expect(buttons(container)[0].style.opacity).toBe('0');
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// Keyboard users never send a mouseenter.
|
|
596
|
+
it('reveals the arrows when focus lands inside', () => {
|
|
597
|
+
const { container } = renderSlider(outside(), {}, {
|
|
598
|
+
'arrows-on-hover': { desktop: true }
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
fireEvent.focus(container.firstElementChild as HTMLElement);
|
|
602
|
+
expect(buttons(container)[0].style.opacity).toBe('1');
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('keeps the arrows visible in the designer, where they must stay selectable', () => {
|
|
606
|
+
const { container } = renderSlider(outside(), { isDesigner: true }, {
|
|
607
|
+
'arrows-on-hover': { desktop: true }
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
expect(buttons(container)[0].style.opacity).toBe('');
|
|
611
|
+
expect(buttons(container)[0].style.pointerEvents).toBe('');
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// "Hover only" on a phone would mean "never", so the option stands down.
|
|
615
|
+
it('keeps the arrows visible on a device that cannot hover', () => {
|
|
616
|
+
setHoverCapability(false);
|
|
617
|
+
const { container } = renderSlider(outside(), {}, {
|
|
618
|
+
'arrows-on-hover': { desktop: true }
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
expect(buttons(container)[0].style.opacity).toBe('');
|
|
622
|
+
expect(buttons(container)[0].style.pointerEvents).toBe('');
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('writes no visibility style at all when the option is off', () => {
|
|
626
|
+
const { container } = renderSlider(outside());
|
|
627
|
+
|
|
628
|
+
expect(buttons(container)[0].style.opacity).toBe('');
|
|
629
|
+
expect(buttons(container)[0].style.transition).toBe('');
|
|
630
|
+
});
|
|
631
|
+
});
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import NextLink from 'next/link';
|
|
5
5
|
import { useSession } from 'next-auth/react';
|
|
6
|
+
import { usePathname } from 'next/navigation';
|
|
6
7
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
8
|
+
import { withReturnPath } from '../auth/auth-destination';
|
|
7
9
|
import useBlockAction from '../hooks/use-block-action';
|
|
8
10
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
9
11
|
import { readToggle } from './product-block-utils';
|
|
@@ -50,8 +52,31 @@ const AccountButtonBlock = ({
|
|
|
50
52
|
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
51
53
|
);
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Whether a guest click records where the shopper was standing.
|
|
57
|
+
*
|
|
58
|
+
* On by default, and it has to be here rather than in the form: this control
|
|
59
|
+
* is the only thing that knows the answer. pz's own route guard redirects to
|
|
60
|
+
* the auth page with no parameter at all, so a login PAGE had nothing to
|
|
61
|
+
* return to — which is why the form's "go back where they came from" mode
|
|
62
|
+
* was unbuildable. Appends `?callbackUrl=<current path>`, which is the key
|
|
63
|
+
* next-auth and the brands' own views already read; the form ignores
|
|
64
|
+
* anything that is not a path on this origin.
|
|
65
|
+
*
|
|
66
|
+
* Nothing changes for a drawer: it never navigates, so there is no href to
|
|
67
|
+
* record on. A brand that tracks its login links by url turns it off.
|
|
68
|
+
*/
|
|
69
|
+
const rememberReturn = readToggle(
|
|
70
|
+
block.properties?.rememberReturn,
|
|
71
|
+
currentBreakpoint,
|
|
72
|
+
true
|
|
73
|
+
);
|
|
74
|
+
const pathname = usePathname();
|
|
75
|
+
|
|
53
76
|
const href = isAuthenticated
|
|
54
77
|
? prop('accountHref', '/account')
|
|
78
|
+
: rememberReturn
|
|
79
|
+
? withReturnPath(prop('loginHref', '/auth'), pathname)
|
|
55
80
|
: prop('loginHref', '/auth');
|
|
56
81
|
const label = isAuthenticated
|
|
57
82
|
? prop('accountLabel', 'Account')
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
emitCommerceEvent,
|
|
11
11
|
readItemsCurrency
|
|
12
12
|
} from '../utils/analytics';
|
|
13
|
+
import { useHostModalCloser } from '../overlays/modal-host-context';
|
|
13
14
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
14
15
|
import {
|
|
15
16
|
DesignerPlaceholder,
|
|
@@ -118,6 +119,19 @@ const AddToCartBlock = ({
|
|
|
118
119
|
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
119
120
|
);
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Whether finishing closes the modal this block is inside. OFF by default
|
|
124
|
+
* here, because this block's success state is something the shopper is meant
|
|
125
|
+
* to read — see `useHostModalCloser`.
|
|
126
|
+
*/
|
|
127
|
+
const closeHostModalOnSuccess = readToggle(
|
|
128
|
+
block.properties?.closeHostModalOnSuccess,
|
|
129
|
+
currentBreakpoint,
|
|
130
|
+
false
|
|
131
|
+
);
|
|
132
|
+
const closeHostModal = useHostModalCloser(closeHostModalOnSuccess);
|
|
133
|
+
|
|
134
|
+
|
|
121
135
|
const label = prop('label', 'Add to Cart');
|
|
122
136
|
const addingLabel = prop('addingLabel', 'Adding…');
|
|
123
137
|
const addedLabel = prop('addedLabel', 'Added');
|
|
@@ -286,6 +300,7 @@ const AddToCartBlock = ({
|
|
|
286
300
|
// by the mini-basket chrome block) has to be refreshed explicitly.
|
|
287
301
|
dispatch(basketApi.util.invalidateTags(['Basket', 'MiniBasket']));
|
|
288
302
|
setFeedback('added');
|
|
303
|
+
closeHostModal();
|
|
289
304
|
emitCommerceEvent('add_to_cart', {
|
|
290
305
|
currency: readItemsCurrency(product),
|
|
291
306
|
items: [buildCommerceItem(product, { quantity })]
|