@akinon/pz-theme 2.0.95-rc.0 → 2.0.95-rc.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.
- package/CHANGELOG.md +34 -0
- 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/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/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
|
+
};
|
|
@@ -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={
|
|
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={
|
|
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
|
-
|
|
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
|
-
: [
|
|
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;
|