@akinon/pz-theme 2.0.94 → 2.0.95-beta.1

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 (60) hide show
  1. package/CHANGELOG.md +16 -0
  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/account-button-block.tsx +25 -0
  30. package/src/blocks/add-to-cart-block.tsx +15 -0
  31. package/src/blocks/auth-field-block.tsx +13 -3
  32. package/src/blocks/auth-flow-block.tsx +45 -3
  33. package/src/blocks/auth-form-block.tsx +337 -54
  34. package/src/blocks/auth-success-block.tsx +85 -0
  35. package/src/blocks/block-renderer-registry.tsx +8 -0
  36. package/src/blocks/button-block.tsx +43 -13
  37. package/src/blocks/captcha-block.tsx +61 -0
  38. package/src/blocks/change-password-form-block.tsx +15 -0
  39. package/src/blocks/forgot-password-block.tsx +132 -9
  40. package/src/blocks/form-state-block.tsx +84 -0
  41. package/src/blocks/newsletter-form-block.tsx +16 -0
  42. package/src/blocks/otp-form-block.tsx +161 -67
  43. package/src/blocks/profile-form-block.tsx +13 -0
  44. package/src/blocks/register-form-block.tsx +362 -110
  45. package/src/blocks/reset-password-block.tsx +717 -0
  46. package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
  47. package/src/chrome/placeholder-slugs.ts +3 -0
  48. package/src/chrome/route-slug.ts +11 -0
  49. package/src/index.ts +10 -0
  50. package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
  51. package/src/overlays/__tests__/modal-registry.test.ts +5 -1
  52. package/src/overlays/modal-chrome-css.ts +8 -1
  53. package/src/overlays/modal-host-context.tsx +66 -0
  54. package/src/overlays/modal-preview-host.tsx +7 -2
  55. package/src/overlays/modal-registry.ts +38 -1
  56. package/src/overlays/overlay-loading.tsx +144 -0
  57. package/src/overlays/overlay-surface.tsx +23 -0
  58. package/src/overlays/overlay-tokens.ts +12 -0
  59. package/src/overlays/theme-modal-mount.tsx +85 -6
  60. 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
+ };
@@ -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 })]
@@ -20,7 +20,7 @@ import { BlockRendererProps } from './block-renderer-registry';
20
20
  * form owns those. Standalone (outside any form) it still renders, but
21
21
  * nothing collects its value, which the designer sees immediately.
22
22
  *
23
- * Properties: fieldType, label, placeholder, showLabel, required, and for
23
+ * Properties: fieldType, label, placeholder, showLabel, required, autoComplete, and for
24
24
  * a `gender` field the two option captions (femaleLabel, maleLabel).
25
25
  * Parts: field, label, input, error.
26
26
  */
@@ -143,6 +143,16 @@ const AuthFieldBlock = ({
143
143
  : preset.type;
144
144
 
145
145
  const label = prop('label', preset.label);
146
+ /**
147
+ * What a password manager is told this box is for.
148
+ *
149
+ * The preset was compiled in, which made one screen wrong on purpose: the
150
+ * `password` preset says `current-password`, so a composed SIGN-UP or a
151
+ * reset screen told the manager to offer a saved password instead of
152
+ * generating a new one, and what the shopper then typed was never saved.
153
+ * Absent keeps the preset, so every published field is untouched.
154
+ */
155
+ const autoComplete = prop('autoComplete', '').trim() || preset.autoComplete;
146
156
  const placeholder = prop('placeholder', '');
147
157
  const showLabel = readToggle(
148
158
  block.properties?.showLabel,
@@ -216,7 +226,7 @@ const AuthFieldBlock = ({
216
226
  */
217
227
  <select
218
228
  name={controlName}
219
- autoComplete={preset.autoComplete}
229
+ autoComplete={autoComplete}
220
230
  defaultValue=""
221
231
  aria-label={showLabel ? undefined : label}
222
232
  aria-invalid={errorMessage ? true : undefined}
@@ -235,7 +245,7 @@ const AuthFieldBlock = ({
235
245
  <input
236
246
  type={controlType}
237
247
  name={controlName}
238
- autoComplete={preset.autoComplete}
248
+ autoComplete={autoComplete}
239
249
  placeholder={placeholder || undefined}
240
250
  aria-label={showLabel ? undefined : label}
241
251
  aria-invalid={errorMessage ? true : undefined}
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useEffect } from 'react';
4
+ import { useSession } from 'next-auth/react';
4
5
  import { getResponsiveValue, partAttrs } from '../utils';
5
6
  import {
6
7
  ActiveAuthStepContext,
7
8
  AuthFlowIdContext,
8
- DEFAULT_AUTH_FLOW_ID,
9
9
  goBackAuthStep,
10
10
  registerAuthFlowMount,
11
11
  useAuthFlowState
@@ -74,9 +74,33 @@ const AuthFlowBlock = ({
74
74
  getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
75
75
  );
76
76
 
77
- const flowId = prop('flowId', '').trim() || DEFAULT_AUTH_FLOW_ID;
77
+ /**
78
+ * The name this journey answers to on the bus.
79
+ *
80
+ * Authored, it is shared: two flows carrying the same name show the same step
81
+ * — deliberate for one journey rendered twice, and for a trigger outside the
82
+ * flow that names `<flowId>:<step>`.
83
+ *
84
+ * Empty, it is the BLOCK's own, because the alternative was a collision the
85
+ * moment a brand did what the composition invites: a journey in the header
86
+ * drawer and a second one in a tab or a page body both fell back to one
87
+ * shared name, so opening the drawer on "register" moved the page's journey
88
+ * with it, and closing the drawer reset it. `DEFAULT_AUTH_FLOW_ID` stays the
89
+ * fallback for controls that know no flow at all — see `resolveAuthStepTarget`
90
+ * — so a composition that wants to be reachable that way names itself.
91
+ */
92
+ const flowId = prop('flowId', '').trim() || `flow-${block.id}`;
78
93
  const initialStep = prop('initialStep', '').trim();
79
94
  const previewStep = prop('previewStep', '').trim();
95
+ /**
96
+ * The step a shopper who is ALREADY signed in lands on.
97
+ *
98
+ * The state picks the step, which is the flow's whole idea — so a member who
99
+ * opens the drawer gets "Hoş geldin / Hesabım / Çıkış" composed out of
100
+ * ordinary blocks instead of the login form they have no use for. Empty
101
+ * keeps the historical behaviour: the flow does not care about the session.
102
+ */
103
+ const signedInStep = prop('signedInStep', '').trim();
80
104
  const backLabel = prop('backLabel', 'Back');
81
105
  const showBack = readToggle(
82
106
  block.properties?.showBack,
@@ -92,6 +116,13 @@ const AuthFlowBlock = ({
92
116
  );
93
117
 
94
118
  const flowState = useAuthFlowState(isDesigner ? '' : flowId);
119
+ const { status } = useSession();
120
+ /**
121
+ * `loading` is not treated as signed in: the flow would open on the member
122
+ * step for a guest and then swap. The other way round — a member seeing the
123
+ * login step for a beat — is the state they are being moved out of anyway.
124
+ */
125
+ const isSignedIn = status === 'authenticated' && !isDesigner;
95
126
 
96
127
  useEffect(() => {
97
128
  if (isDesigner) return undefined;
@@ -124,9 +155,20 @@ const AuthFlowBlock = ({
124
155
  if (selected) return stepKeyOf(selected, currentBreakpoint);
125
156
  }
126
157
 
158
+ /**
159
+ * A step the shopper NAVIGATED to still wins over the member step: a
160
+ * signed-in shopper who taps "change password" inside the journey is
161
+ * asking for that step, and answering with the welcome card would be the
162
+ * flow overruling them. The member step is the ENTRY, so it only leads
163
+ * while the bus has nothing to say.
164
+ */
127
165
  const candidates = isDesigner
128
166
  ? [previewStep, initialStep]
129
- : [flowState?.active ?? '', initialStep];
167
+ : [
168
+ flowState?.active ?? '',
169
+ ...(isSignedIn ? [signedInStep] : []),
170
+ initialStep
171
+ ];
130
172
 
131
173
  for (const candidate of candidates) {
132
174
  if (candidate && stepKeys.includes(candidate)) return candidate;