@akinon/pz-theme 2.0.165-rc.0 → 2.0.165

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.
@@ -8,8 +8,28 @@ import {
8
8
  } from '@akinon/next/data/client/account';
9
9
  import { getResponsiveValue, partAttrs } from '../utils';
10
10
  import { useHostModalCloser } from '../overlays/modal-host-context';
11
+ import {
12
+ AuthFieldErrors,
13
+ AuthFieldErrorsProvider,
14
+ clearAuthFieldError,
15
+ collectAuthFieldErrors,
16
+ eventFieldName,
17
+ fieldBoxOf,
18
+ isVisibleControl
19
+ } from '../auth/auth-field-errors';
20
+ import {
21
+ AuthFormErrorProvider,
22
+ containsAuthErrorBlock
23
+ } from '../auth/form-error-context';
24
+ import {
25
+ AuthFormStateProvider,
26
+ AuthFormStateValue,
27
+ showsInFormState
28
+ } from '../auth/form-state-context';
29
+ import { FormFieldDefaultsProvider } from '../auth/form-field-defaults';
11
30
  import { BlockRendererProps } from './block-renderer-registry';
12
31
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
32
+ import { renderComposedChildren } from './render-composed-children';
13
33
 
14
34
  interface ProfileLike {
15
35
  first_name?: string;
@@ -22,6 +42,102 @@ interface ProfileLike {
22
42
  email_allowed?: boolean;
23
43
  }
24
44
 
45
+ /**
46
+ * Fields the profile shows but this form cannot change: commerce moves the
47
+ * e-mail through its own verified flow, and its profile serializer marks it
48
+ * read-only, so a value typed here would be silently dropped.
49
+ */
50
+ const READ_ONLY_FIELDS = ['email'];
51
+
52
+ type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
53
+
54
+ /** A tick whose value is the plain agreement flag rather than a brand's own. */
55
+ const isFlagValue = (value: string) => value === 'true' || value === 'on';
56
+
57
+ /**
58
+ * What a composed profile form saves: the answers of the fields the shopper can
59
+ * actually see, and nothing else.
60
+ *
61
+ * The profile is saved with a PATCH (`updateProfile` in @akinon/next, commerce's
62
+ * `partial_update`), so a key that is not sent is a key that is not changed.
63
+ * That is what makes leaving things out the safe default:
64
+ * - a field the composition does not place, or hides on this breakpoint, keeps
65
+ * its stored value — a consent tick the design took off mobile must not
66
+ * post "unticked" and withdraw a consent the shopper never saw;
67
+ * - a question that was never answered and is still empty (a gender left on
68
+ * its empty option, a half-picked date, a box that started blank) is not
69
+ * written, so it cannot turn into an error from commerce's validation;
70
+ * - a box that started filled and was emptied IS an answer — the shopper
71
+ * deleted their phone — so it is sent as `''`, as the fixed form sent it;
72
+ * - a tick is an answer either way: a visible consent row posts `true` or
73
+ * `false`, because leaving it unticked is how a shopper withdraws. A custom
74
+ * tick with a value of its own posts that value, or `''` when unticked.
75
+ *
76
+ * Visibility is the FIELD's (see `fieldBoxOf`), not the native control's, and
77
+ * first control per name wins — the rules the field validation applies too.
78
+ */
79
+ export const readProfilePayload = (
80
+ form: HTMLFormElement
81
+ ): Record<string, string | boolean> => {
82
+ const payload: Record<string, string | boolean> = {};
83
+ const answered = new Set<string>();
84
+
85
+ form
86
+ .querySelectorAll<FormControl>('input[name], select[name], textarea[name]')
87
+ .forEach((control) => {
88
+ const { name } = control;
89
+
90
+ if (!name || answered.has(name) || READ_ONLY_FIELDS.includes(name)) {
91
+ return;
92
+ }
93
+
94
+ // Disabled controls (or a disabled fieldset around them) are what the
95
+ // form shows but does not own.
96
+ if (control.matches(':disabled')) return;
97
+
98
+ if (!isVisibleControl(fieldBoxOf(control))) return;
99
+
100
+ if (control instanceof HTMLInputElement && control.type === 'checkbox') {
101
+ answered.add(name);
102
+
103
+ if (isFlagValue(control.value)) {
104
+ payload[name] = control.checked;
105
+ } else {
106
+ payload[name] = control.checked ? control.value : '';
107
+ }
108
+
109
+ return;
110
+ }
111
+
112
+ if (control instanceof HTMLInputElement && control.type === 'radio') {
113
+ // The group's answer is its checked radio; one with none checked is a
114
+ // question the shopper left, and later radios may still answer it.
115
+ if (!control.checked) return;
116
+
117
+ answered.add(name);
118
+ payload[name] = control.value;
119
+
120
+ return;
121
+ }
122
+
123
+ if (control.value === '') {
124
+ // Emptied by the shopper: the box started with the stored value.
125
+ const wasFilled =
126
+ (control instanceof HTMLInputElement && control.type !== 'hidden') ||
127
+ control instanceof HTMLTextAreaElement
128
+ ? control.defaultValue !== ''
129
+ : false;
130
+
131
+ if (!wasFilled) return;
132
+ }
133
+
134
+ answered.add(name);
135
+ payload[name] = control.value;
136
+ });
137
+
138
+ return payload;
139
+ };
140
+
25
141
  const EMPTY_FORM = {
26
142
  first_name: '',
27
143
  last_name: '',
@@ -49,15 +165,27 @@ const SUBMIT_ALIGNMENTS: Record<string, string> = {
49
165
  * E-mail is rendered read-only on purpose: `updateProfile` has no e-mail field
50
166
  * — commerce moves it through a separate verified `updateEmail` flow — so an
51
167
  * editable input here would silently discard what the shopper typed.
52
- * Properties: submitLabel, submitAlign, showPhone, showEmail, showBirthDate,
53
- * showLabels, firstNameLabel, lastNameLabel, emailLabel, phoneLabel,
54
- * birthDateLabel, helpLabel, errorMessage, successMessage.
168
+ *
169
+ * Composable, the way the Register Form is: with children the form draws THEM
170
+ * — an `auth-field` per input (gender, a day/month/year birth date, a brand's
171
+ * own custom field), an `auth-consent` per contact channel, any text, link or
172
+ * group around them — starts each one from the shopper's profile, and saves
173
+ * what the visible ones say (see `readProfilePayload`). Left empty it renders
174
+ * the fixed rows exactly as before, so a published composition is untouched.
175
+ *
176
+ * Properties: submitLabel, submitAlign, showName, showPhone, showEmail,
177
+ * showBirthDate, showLabels, firstNameLabel, lastNameLabel, emailLabel,
178
+ * phoneLabel, birthDateLabel, helpLabel, errorMessage, successMessage,
179
+ * requiredMessage, invalidMessage, closeHostModalOnSuccess.
55
180
  * Parts: form, field, label, input, help, error, success, submit.
56
181
  */
57
182
  const ProfileFormBlock = ({
58
183
  block,
184
+ placeholderId,
185
+ sectionId,
59
186
  currentBreakpoint = 'desktop',
60
- isDesigner = false
187
+ isDesigner = false,
188
+ selectedBlockId
61
189
  }: BlockRendererProps) => {
62
190
  const text = (key: string, fallback: string) =>
63
191
  String(
@@ -88,9 +216,13 @@ const ProfileFormBlock = ({
88
216
  'successMessage',
89
217
  'Your profile has been saved.'
90
218
  );
219
+ // The copy a composed field shows under itself when it is left empty or
220
+ // filled in wrongly — see `auth/auth-field-errors.tsx`.
221
+ const requiredMessage = text('requiredMessage', 'This field is required.');
222
+ const invalidMessage = text('invalidMessage', 'Please check this field.');
91
223
  // Lets a composition scope the form to one field (e.g. a birth-date-only
92
224
  // drawer). The hidden name fields still submit their loaded values, so the
93
- // whole-record PUT keeps them untouched.
225
+ // save leaves them as they were.
94
226
  const showName = readToggle(
95
227
  block.properties?.showName,
96
228
  currentBreakpoint,
@@ -122,16 +254,30 @@ const ProfileFormBlock = ({
122
254
 
123
255
  // Guests have no profile endpoint; skipping avoids a guaranteed 403 on every
124
256
  // page that hosts this block.
125
- const { data: profile, isLoading } = useGetProfileInfoQuery(undefined, {
257
+ const {
258
+ data: profile,
259
+ isLoading,
260
+ isError: profileFailed
261
+ } = useGetProfileInfoQuery(undefined, {
126
262
  skip: !isAuthenticated
127
263
  });
128
264
  const [updateProfile, { isLoading: isSaving }] = useUpdateProfileMutation();
129
265
 
130
266
  const [values, setValues] = useState(EMPTY_FORM);
131
267
  const [status, setStatus] = useState<'idle' | 'saved' | 'error'>('idle');
268
+ const [fieldErrors, setFieldErrors] = useState<AuthFieldErrors>({});
132
269
 
133
270
  const loadedProfile = (profile ?? null) as ProfileLike | null;
134
271
 
272
+ /**
273
+ * The children a composition authored. Hidden ones are dropped for a shopper
274
+ * and kept on the canvas, so an author can dress a row they switched off.
275
+ */
276
+ const childBlocks = (block.blocks || []).filter((childBlock) =>
277
+ isDesigner ? true : !childBlock.hidden
278
+ );
279
+ const isComposed = childBlocks.length > 0;
280
+
135
281
  useEffect(() => {
136
282
  if (!loadedProfile) return;
137
283
 
@@ -168,9 +314,9 @@ const ProfileFormBlock = ({
168
314
  last_name: values.last_name,
169
315
  phone: values.phone,
170
316
  date_of_birth: values.date_of_birth,
171
- // The endpoint replaces the whole profile record, so the fields this
172
- // block does not render are echoed back untouched — otherwise saving a
173
- // name would silently clear the shopper's notification consents.
317
+ // The fields this block does not render are echoed back as loaded.
318
+ // The save is a PATCH, so leaving them out would keep them too; the
319
+ // echo is kept because published forms have always sent it.
174
320
  gender: loadedProfile?.gender ?? '',
175
321
  sms_allowed: loadedProfile?.sms_allowed ?? false,
176
322
  email_allowed: loadedProfile?.email_allowed ?? false
@@ -183,8 +329,201 @@ const ProfileFormBlock = ({
183
329
  }
184
330
  };
185
331
 
332
+ /**
333
+ * The composed save: the fields validate themselves (their own Required
334
+ * toggle, their own type), then only the answers on screen are sent.
335
+ */
336
+ const submitComposed = async (event: React.FormEvent<HTMLFormElement>) => {
337
+ event.preventDefault();
338
+
339
+ // A submit inside the designer must never write to the shopper's account.
340
+ if (isDesigner || isSaving) return;
341
+
342
+ setStatus('idle');
343
+
344
+ const { errors, firstInvalid } = collectAuthFieldErrors(
345
+ event.currentTarget,
346
+ { required: requiredMessage, invalid: invalidMessage }
347
+ );
348
+
349
+ setFieldErrors(errors);
350
+
351
+ if (firstInvalid) {
352
+ firstInvalid.focus();
353
+
354
+ return;
355
+ }
356
+
357
+ const payload = readProfilePayload(event.currentTarget);
358
+
359
+ try {
360
+ // Nothing on screen to save is still a save the shopper asked for; it
361
+ // just has nothing to change.
362
+ if (Object.keys(payload).length > 0) {
363
+ await updateProfile(payload as never).unwrap();
364
+ }
365
+
366
+ setStatus('saved');
367
+ closeHostModal();
368
+ } catch {
369
+ setStatus('error');
370
+ }
371
+ };
372
+
373
+ /** A message describes what a field WAS: it goes once that field is edited. */
374
+ const handleComposedInput = (event: React.FormEvent<HTMLFormElement>) => {
375
+ const name = eventFieldName(event.target);
376
+
377
+ setStatus('idle');
378
+ setFieldErrors((current) => clearAuthFieldError(current, name));
379
+ };
380
+
186
381
  if (!isDesigner && !isAuthenticated) return null;
187
382
 
383
+ if (isComposed) {
384
+ /**
385
+ * The fields are uncontrolled — each starts from the profile and the form
386
+ * reads them back on submit — so they are only drawn once the profile is
387
+ * there to start from. Drawing them earlier would show empty boxes and
388
+ * unticked consents that a quick "save" would then write back.
389
+ *
390
+ * The canvas draws them regardless, empty when the editor has no profile,
391
+ * so the author always has something to arrange.
392
+ */
393
+ if (!isDesigner && !loadedProfile) {
394
+ // Still loading: nothing yet. Failed: say so, rather than leave an empty
395
+ // drawer that looks like a form nobody built.
396
+ if (!profileFailed || isLoading) return null;
397
+
398
+ return (
399
+ <div
400
+ data-profile-form=""
401
+ data-profile-form-state="unavailable"
402
+ role="alert"
403
+ {...partAttrs('error', { invalid: true })}
404
+ >
405
+ {errorMessage}
406
+ </div>
407
+ );
408
+ }
409
+
410
+ const errorMessageNode = status === 'error' ? errorMessage : '';
411
+ const formStateValue: AuthFormStateValue = {
412
+ screen: 'form',
413
+ busy: isSaving,
414
+ hasError: status === 'error'
415
+ };
416
+
417
+ return (
418
+ <AuthFormErrorProvider value={errorMessageNode}>
419
+ <AuthFormStateProvider value={formStateValue}>
420
+ <AuthFieldErrorsProvider value={fieldErrors}>
421
+ <FormFieldDefaultsProvider
422
+ value={{
423
+ values: (loadedProfile ?? {}) as Record<string, unknown>,
424
+ readOnly: READ_ONLY_FIELDS
425
+ }}
426
+ >
427
+ <form
428
+ // Remounted once a profile arrives on the canvas, so the
429
+ // fields pick up the values they start from.
430
+ key={loadedProfile ? 'profile' : 'empty'}
431
+ data-profile-form=""
432
+ data-profile-form-composed=""
433
+ data-profile-form-state={isSaving ? 'busy' : status}
434
+ onSubmit={submitComposed}
435
+ onInput={handleComposedInput}
436
+ onChange={handleComposedInput}
437
+ // The fields draw their own messages; see auth-field-errors.
438
+ noValidate
439
+ {...partAttrs('form', {
440
+ loading: isSaving,
441
+ invalid: status === 'error'
442
+ })}
443
+ style={{
444
+ display: 'flex',
445
+ flexDirection: 'column',
446
+ gap: 'var(--profile-form-gap, 12px)'
447
+ }}
448
+ >
449
+ {renderComposedChildren({
450
+ // A Form State container shows only in the state it is
451
+ // scoped to (submitting, refused); anything else is shared
452
+ // chrome. There is no success SCREEN here — the form stays
453
+ // and says "saved" — so a success-scoped container never
454
+ // shows to a shopper, as in a register form.
455
+ blocks: childBlocks.filter((childBlock) =>
456
+ showsInFormState({
457
+ block: childBlock,
458
+ state: formStateValue,
459
+ isDesigner,
460
+ selectedBlockId: selectedBlockId ?? undefined,
461
+ currentBreakpoint
462
+ })
463
+ ),
464
+ placeholderId,
465
+ sectionId,
466
+ isDesigner,
467
+ selectedBlockId,
468
+ currentBreakpoint
469
+ })}
470
+
471
+ {helpLabel && (
472
+ <div data-profile-form-help="" {...partAttrs('help')}>
473
+ {helpLabel}
474
+ </div>
475
+ )}
476
+
477
+ {/* The fallback for a composition that has not placed a Form
478
+ Error block of its own. */}
479
+ {status === 'error' && !containsAuthErrorBlock(childBlocks) && (
480
+ <div
481
+ data-profile-form-error=""
482
+ role="alert"
483
+ {...partAttrs('error', { invalid: true })}
484
+ >
485
+ {errorMessage}
486
+ </div>
487
+ )}
488
+
489
+ {status === 'saved' && (
490
+ <div
491
+ data-profile-form-success=""
492
+ role="status"
493
+ {...partAttrs('success')}
494
+ >
495
+ {successMessage}
496
+ </div>
497
+ )}
498
+
499
+ <button
500
+ type="submit"
501
+ data-profile-form-submit=""
502
+ disabled={isSaving}
503
+ aria-busy={isSaving}
504
+ {...partAttrs('submit', { loading: isSaving })}
505
+ style={{
506
+ padding: '12px 20px',
507
+ background: 'var(--profile-form-cta-bg, #111827)',
508
+ color: 'var(--profile-form-cta-color, #ffffff)',
509
+ border: 'none',
510
+ cursor: isSaving ? 'not-allowed' : 'pointer',
511
+ opacity: isSaving ? 0.7 : 1,
512
+ ...(SUBMIT_ALIGNMENTS[submitAlign]
513
+ ? { alignSelf: SUBMIT_ALIGNMENTS[submitAlign] }
514
+ : null)
515
+ }}
516
+ >
517
+ {submitLabel}
518
+ </button>
519
+ </form>
520
+ </FormFieldDefaultsProvider>
521
+ </AuthFieldErrorsProvider>
522
+ </AuthFormStateProvider>
523
+ </AuthFormErrorProvider>
524
+ );
525
+ }
526
+
188
527
  if (isDesigner && !loadedProfile && !isLoading) {
189
528
  return (
190
529
  <DesignerPlaceholder
@@ -286,7 +625,11 @@ const ProfileFormBlock = ({
286
625
  )}
287
626
 
288
627
  {status === 'saved' && (
289
- <div data-profile-form-success="" role="status" {...partAttrs('success')}>
628
+ <div
629
+ data-profile-form-success=""
630
+ role="status"
631
+ {...partAttrs('success')}
632
+ >
290
633
  {successMessage}
291
634
  </div>
292
635
  )}
@@ -16,8 +16,11 @@ import {
16
16
  AuthFieldErrors,
17
17
  AuthFieldErrorsProvider,
18
18
  clearAuthFieldError,
19
+ eventFieldName,
19
20
  collectAuthFieldErrors,
20
- focusFirstAuthFieldError
21
+ fieldBoxOf,
22
+ focusFirstAuthFieldError,
23
+ isVisibleControl
21
24
  } from '../auth/auth-field-errors';
22
25
  import {
23
26
  AuthFormErrorProvider,
@@ -395,11 +398,9 @@ const RegisterFormBlock = ({
395
398
  }, [formHtmlId, isSubmitting]);
396
399
 
397
400
  const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
398
- const target = event.target as { name?: string } | null;
401
+ const name = eventFieldName(event.target);
399
402
 
400
- setFieldErrors((current) =>
401
- clearAuthFieldError(current, target?.name || '')
402
- );
403
+ setFieldErrors((current) => clearAuthFieldError(current, name));
403
404
  };
404
405
 
405
406
  const submit = async (event: React.FormEvent<HTMLFormElement>) => {
@@ -551,7 +552,12 @@ const RegisterFormBlock = ({
551
552
  `input[type="checkbox"][name="${name}"]`
552
553
  ) as HTMLInputElement | null;
553
554
 
554
- return control ? control.checked : undefined;
555
+ // A tick the design took off this breakpoint is a question nobody was
556
+ // asked here: it falls back to the blanket answer, instead of posting
557
+ // the refusal its untouched box would read as.
558
+ if (!control || !isVisibleControl(fieldBoxOf(control))) return undefined;
559
+
560
+ return control.checked;
555
561
  };
556
562
 
557
563
  const agreedToLegal = legalControl ? agreedToTerms : true;
@@ -10,6 +10,7 @@ import {
10
10
  AuthFieldErrors,
11
11
  AuthFieldErrorsProvider,
12
12
  clearAuthFieldError,
13
+ eventFieldName,
13
14
  collectAuthFieldErrors,
14
15
  focusFirstAuthFieldError
15
16
  } from '../auth/auth-field-errors';
@@ -290,11 +291,9 @@ const ResetPasswordBlock = ({
290
291
  const [passwordReset, { isLoading, isSuccess }] = usePasswordResetMutation();
291
292
 
292
293
  const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
293
- const target = event.target as { name?: string } | null;
294
+ const name = eventFieldName(event.target);
294
295
 
295
- setFieldErrors((current) =>
296
- clearAuthFieldError(current, target?.name || '')
297
- );
296
+ setFieldErrors((current) => clearAuthFieldError(current, name));
298
297
  };
299
298
 
300
299
  /**
@@ -19,7 +19,7 @@ const TextBlock = ({
19
19
  }: BlockRendererProps) => {
20
20
  const { locale } = useLocalization();
21
21
  const localize = useLocalizeHref();
22
- const { defaultLocale } = useValueLocale();
22
+ const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE || 'en';
23
23
  const themeSettings = useThemeSettingsContext();
24
24
 
25
25
  const getLocalizedContent = (): string => {
@@ -35,10 +35,7 @@ const TextBlock = ({
35
35
 
36
36
  if (typeof value === 'object' && value !== null) {
37
37
  return (
38
- value[locale] ||
39
- (defaultLocale && value[defaultLocale]) ||
40
- Object.values(value)[0] ||
41
- ''
38
+ value[locale] || value[defaultLocale] || Object.values(value)[0] || ''
42
39
  );
43
40
  }
44
41