@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-captcha.test.tsx +261 -0
  4. package/src/__tests__/auth-commerce-events.test.tsx +5 -1
  5. package/src/__tests__/auth-destination.test.tsx +348 -0
  6. package/src/__tests__/auth-error-reading.test.ts +232 -0
  7. package/src/__tests__/auth-flow-scope.test.tsx +162 -0
  8. package/src/__tests__/auth-form-error-block.test.tsx +2 -0
  9. package/src/__tests__/auth-form-success-block.test.tsx +429 -0
  10. package/src/__tests__/auth-form-validation.test.tsx +2 -0
  11. package/src/__tests__/auth-slot-coverage.test.ts +9 -1
  12. package/src/__tests__/auth-step-title.test.tsx +2 -0
  13. package/src/__tests__/block-action-audience.test.ts +154 -0
  14. package/src/__tests__/detached-submit-state.test.tsx +80 -2
  15. package/src/__tests__/forgot-password-composed.test.tsx +2 -0
  16. package/src/__tests__/form-state-composition.test.tsx +484 -0
  17. package/src/__tests__/modal-login-close.test.tsx +366 -0
  18. package/src/__tests__/otp-form-composed.test.tsx +2 -0
  19. package/src/__tests__/password-reset-step.test.tsx +2 -0
  20. package/src/__tests__/register-form-composed.test.tsx +253 -4
  21. package/src/__tests__/registry-parity.test.ts +12 -0
  22. package/src/__tests__/reset-password-composed.test.tsx +385 -0
  23. package/src/auth/auth-captcha.tsx +125 -0
  24. package/src/auth/auth-destination.ts +168 -0
  25. package/src/auth/auth-field-errors.tsx +31 -0
  26. package/src/auth/form-state-context.tsx +170 -0
  27. package/src/auth/form-success-context.tsx +56 -0
  28. package/src/auth/read-auth-error.ts +332 -0
  29. package/src/blocks/__tests__/slider-block.test.tsx +265 -1
  30. package/src/blocks/account-button-block.tsx +25 -0
  31. package/src/blocks/add-to-cart-block.tsx +15 -0
  32. package/src/blocks/auth-field-block.tsx +13 -3
  33. package/src/blocks/auth-flow-block.tsx +45 -3
  34. package/src/blocks/auth-form-block.tsx +337 -54
  35. package/src/blocks/auth-success-block.tsx +85 -0
  36. package/src/blocks/block-renderer-registry.tsx +8 -0
  37. package/src/blocks/button-block.tsx +43 -13
  38. package/src/blocks/captcha-block.tsx +61 -0
  39. package/src/blocks/change-password-form-block.tsx +15 -0
  40. package/src/blocks/forgot-password-block.tsx +132 -9
  41. package/src/blocks/form-state-block.tsx +84 -0
  42. package/src/blocks/newsletter-form-block.tsx +16 -0
  43. package/src/blocks/otp-form-block.tsx +161 -67
  44. package/src/blocks/profile-form-block.tsx +13 -0
  45. package/src/blocks/register-form-block.tsx +362 -110
  46. package/src/blocks/reset-password-block.tsx +717 -0
  47. package/src/blocks/slider-block.tsx +170 -4
  48. package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
  49. package/src/chrome/placeholder-slugs.ts +3 -0
  50. package/src/chrome/route-slug.ts +11 -0
  51. package/src/index.ts +10 -0
  52. package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
  53. package/src/overlays/__tests__/modal-registry.test.ts +5 -1
  54. package/src/overlays/modal-chrome-css.ts +8 -1
  55. package/src/overlays/modal-host-context.tsx +66 -0
  56. package/src/overlays/modal-preview-host.tsx +7 -2
  57. package/src/overlays/modal-registry.ts +38 -1
  58. package/src/overlays/overlay-loading.tsx +144 -0
  59. package/src/overlays/overlay-surface.tsx +23 -0
  60. package/src/overlays/overlay-tokens.ts +12 -0
  61. package/src/overlays/theme-modal-mount.tsx +85 -6
  62. 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 })]