@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.
@@ -56,6 +56,51 @@ export interface AuthFieldValidation {
56
56
 
57
57
  type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
58
58
 
59
+ /**
60
+ * Every control a form checks: the named ones, plus the parts of a compound
61
+ * field.
62
+ *
63
+ * A compound field — the day, month and year selects of a birth date — submits
64
+ * ONE value under one name through a hidden input, and a hidden input is never
65
+ * validated. So the visible parts carry no name of their own (they would post
66
+ * three stray keys) and say which field they belong to through
67
+ * `data-field-name` instead; their `required` is then reported under that
68
+ * field's name, which is where the field draws its message.
69
+ */
70
+ const VALIDATED_CONTROLS =
71
+ 'input[name], select[name], textarea[name], [data-field-name]';
72
+
73
+ /**
74
+ * The box a control is presented in: its Auth Field or Consent Row when it has
75
+ * one, so "can the shopper see this question" is asked of the field rather
76
+ * than of the native control. A brand that draws its own tick or radio hides
77
+ * the native input and styles a sibling — the question is still on screen —
78
+ * and a compound field's hidden input is visible exactly when its parts are.
79
+ */
80
+ export const fieldBoxOf = (control: HTMLElement): HTMLElement =>
81
+ control.closest<HTMLElement>('[data-auth-field], [data-auth-consent]') ??
82
+ (control instanceof HTMLInputElement && control.type === 'hidden'
83
+ ? (control.parentElement ?? control)
84
+ : control);
85
+
86
+ /** The name a control's message is filed under. */
87
+ const fieldNameOf = (control: FormControl): string =>
88
+ control.name || control.dataset.fieldName || '';
89
+
90
+ /**
91
+ * The field an `input`/`change` event was about, for the form that clears that
92
+ * field's message as soon as it is edited. Same rule as the collector: a
93
+ * compound field's part answers with the field's name.
94
+ */
95
+ export const eventFieldName = (target: EventTarget | null): string => {
96
+ const element = target as {
97
+ name?: string;
98
+ dataset?: { fieldName?: string };
99
+ } | null;
100
+
101
+ return element?.name || element?.dataset?.fieldName || '';
102
+ };
103
+
59
104
  /**
60
105
  * Whether a control is actually on screen for this breakpoint.
61
106
  *
@@ -65,7 +110,7 @@ type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
65
110
  * scrolled out of view, sized to nothing, or covered by another is still a
66
111
  * control the shopper can be sent to.
67
112
  */
68
- const isVisibleControl = (control: FormControl): boolean => {
113
+ export const isVisibleControl = (control: HTMLElement): boolean => {
69
114
  if (typeof control.checkVisibility === 'function') {
70
115
  return control.checkVisibility();
71
116
  }
@@ -98,9 +143,7 @@ export const collectAuthFieldErrors = (
98
143
  let firstInvalid: HTMLElement | null = null;
99
144
 
100
145
  const controls = Array.from(
101
- form.querySelectorAll<FormControl>(
102
- 'input[name], select[name], textarea[name]'
103
- )
146
+ form.querySelectorAll<FormControl>(VALIDATED_CONTROLS)
104
147
  );
105
148
 
106
149
  controls.forEach((control) => {
@@ -116,12 +159,15 @@ export const collectAuthFieldErrors = (
116
159
  // `noValidate` had already suppressed the browser's bubble, and the
117
160
  // message this function returns was drawn inside the hidden wrapper. A
118
161
  // field the shopper cannot see is not one they can be asked to fill in.
119
- if (!isVisibleControl(control)) return;
162
+ if (!isVisibleControl(fieldBoxOf(control))) return;
163
+
164
+ // First one wins per name: a radio group, a compound field's parts, or a
165
+ // name a composition duplicated, is one question to the shopper rather
166
+ // than several.
167
+ const name = fieldNameOf(control);
120
168
 
121
- // First one wins per name: a radio group, or a name a composition
122
- // duplicated, is one question to the shopper rather than several.
123
- if (!errors[control.name]) {
124
- errors[control.name] = control.validity.valueMissing
169
+ if (!errors[name]) {
170
+ errors[name] = control.validity.valueMissing
125
171
  ? messages.required
126
172
  : messages.invalid;
127
173
  }
@@ -151,13 +197,13 @@ export const focusFirstAuthFieldError = (
151
197
  if (!form || Object.keys(errors).length === 0) return;
152
198
 
153
199
  const controls = Array.from(
154
- form.querySelectorAll<FormControl>(
155
- 'input[name], select[name], textarea[name]'
156
- )
200
+ form.querySelectorAll<FormControl>(VALIDATED_CONTROLS)
157
201
  );
158
202
 
159
203
  const first = controls.find(
160
- (control) => Boolean(errors[control.name]) && isVisibleControl(control)
204
+ (control) =>
205
+ Boolean(errors[fieldNameOf(control)]) &&
206
+ isVisibleControl(fieldBoxOf(control))
161
207
  );
162
208
 
163
209
  first?.focus();
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ /**
6
+ * What a composed form already knows about each of its fields, keyed by the
7
+ * name the field submits under.
8
+ *
9
+ * A sign-up starts empty, so the field blocks were written as empty controls
10
+ * the form reads back through `FormData`. A form that EDITS a record — the
11
+ * profile form — has to start from the record instead: the shopper's name in
12
+ * the name box, their birth date in the date selects, their current answer in
13
+ * each consent tick. The fields are child blocks at any depth, so the form has
14
+ * no reference to hand a value to; each field reads its own entry here, the
15
+ * same way it reads its error message from `auth-field-errors.tsx`.
16
+ *
17
+ * `readOnly` names the fields the form shows but cannot change — the profile's
18
+ * e-mail, which commerce only moves through its own verified flow. Such a
19
+ * field is drawn disabled, so it is neither submitted nor validated.
20
+ *
21
+ * Outside such a form nothing is provided and every field starts empty, which
22
+ * is exactly what a register or auth form has always drawn.
23
+ */
24
+ export interface FormFieldDefaultsValue {
25
+ values: Readonly<Record<string, unknown>>;
26
+ readOnly: ReadonlyArray<string>;
27
+ }
28
+
29
+ const NO_DEFAULTS: FormFieldDefaultsValue = { values: {}, readOnly: [] };
30
+
31
+ const FormFieldDefaultsContext =
32
+ React.createContext<FormFieldDefaultsValue>(NO_DEFAULTS);
33
+
34
+ export const FormFieldDefaultsProvider = FormFieldDefaultsContext.Provider;
35
+
36
+ /** The value a text-like control starts with: '' when the form has none. */
37
+ export const useFormFieldDefault = (name: string): string => {
38
+ const { values } = React.useContext(FormFieldDefaultsContext);
39
+ const value = name ? values[name] : undefined;
40
+
41
+ if (value === null || value === undefined) return '';
42
+
43
+ return String(value);
44
+ };
45
+
46
+ /**
47
+ * Whether a tick starts ticked. Only an explicit `true` counts: commerce keeps
48
+ * `null` for a consent the shopper was never asked about, and drawing that as
49
+ * ticked would put an agreement in their mouth.
50
+ */
51
+ export const useFormFieldChecked = (
52
+ name: string,
53
+ tickedValue = 'true'
54
+ ): boolean => {
55
+ const { values } = React.useContext(FormFieldDefaultsContext);
56
+ const stored = name ? values[name] : undefined;
57
+
58
+ if (stored === true) return true;
59
+ // A custom tick that sends its own value (`yes`, `1`) is ticked when that is
60
+ // what is stored.
61
+ return (
62
+ tickedValue !== 'true' &&
63
+ stored !== undefined &&
64
+ stored !== null &&
65
+ String(stored) === tickedValue
66
+ );
67
+ };
68
+
69
+ /** Whether the hosting form shows this field without letting it change. */
70
+ export const useFormFieldReadOnly = (name: string): boolean => {
71
+ const { readOnly } = React.useContext(FormFieldDefaultsContext);
72
+
73
+ return Boolean(name) && readOnly.includes(name);
74
+ };
@@ -2,31 +2,9 @@ import React from 'react';
2
2
  import { render } from '@testing-library/react';
3
3
  import TextBlock from '../text-block';
4
4
 
5
- // The page's language and the storefront's default, as the localization
6
- // provider reports them. Most cases run on an English-default store showing
7
- // English; the fallback case moves both.
8
- const mockLocalization = { locale: 'en-us', defaultLocaleValue: 'en-us' };
9
-
10
- jest.mock('@akinon/next/hooks', () => ({
11
- __esModule: true,
12
- useLocalization: () => ({
13
- t: (path: string) => path,
14
- setLocale: () => {},
15
- setCurrency: () => {},
16
- locale: mockLocalization.locale,
17
- currency: 'USD',
18
- locales: [{ value: 'en-us' }, { value: 'tr-tr' }],
19
- currencies: ['USD'],
20
- defaultLocaleValue: mockLocalization.defaultLocaleValue,
21
- defaultCurrencyCode: 'USD',
22
- localeUrlStrategy: 'hide-default-locale'
23
- })
24
- }));
25
-
26
- beforeEach(() => {
27
- mockLocalization.locale = 'en-us';
28
- mockLocalization.defaultLocaleValue = 'en-us';
29
- });
5
+ // The mocked useLocalization (jest/__mocks__/akinon-next-hooks.js) returns
6
+ // locale: 'en-us'. process.env.NEXT_PUBLIC_DEFAULT_LOCALE is unset in the
7
+ // jest env, so the component's defaultLocale falls back to 'en'.
30
8
 
31
9
  const renderBlock = (value: unknown, styles?: Record<string, unknown>) => {
32
10
  const block: any = { id: 'b1', type: 'text', value, styles };
@@ -56,16 +34,10 @@ describe('TextBlock value-shape resolution', () => {
56
34
  );
57
35
  });
58
36
 
59
- it("falls back to the storefront's default language when the active one is absent", () => {
60
- // A Turkish-default store showing German. The entry under `en` is what
61
- // the block used to show: its default was read from an env variable
62
- // that falls back to English, not from the storefront (ZERO-5296).
63
- mockLocalization.locale = 'de-de';
64
- mockLocalization.defaultLocaleValue = 'tr-tr';
65
-
66
- const { container } = renderBlock({ en: 'EN', 'tr-tr': 'TR' });
37
+ it('falls back to defaultLocale ("en") when active locale is absent', () => {
38
+ const { container } = renderBlock({ en: 'EN', fr: 'FR' });
67
39
  expect((container.querySelector('div') as HTMLDivElement).innerHTML).toBe(
68
- 'TR'
40
+ 'EN'
69
41
  );
70
42
  });
71
43
 
@@ -3,6 +3,7 @@
3
3
  import React from 'react';
4
4
 
5
5
  import { useAuthFieldError } from '../auth/auth-field-errors';
6
+ import { useFormFieldChecked } from '../auth/form-field-defaults';
6
7
  import { getResponsiveValue, partAttrs } from '../utils';
7
8
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
8
9
  import { BlockRendererProps } from './block-renderer-registry';
@@ -27,12 +28,37 @@ import { BlockRendererProps } from './block-renderer-registry';
27
28
  * reads out of `FormData` — and an unticked checkbox simply is not in there,
28
29
  * which is exactly the "did they agree" question the form asks.
29
30
  *
31
+ * A contact-channel tick (`email`, `sms`, `call`, `whatsapp`) is one channel's
32
+ * answer under the flag commerce stores for it (`email_allowed` …). A register
33
+ * form reads it in place of its blanket marketing answer for that channel; a
34
+ * profile form starts it from the shopper's stored answer and saves it back.
35
+ *
30
36
  * Properties: consentType, text, required, and for a `custom` tick the
31
37
  * name it submits and the value it sends when ticked.
32
38
  * Parts: consent, checkbox, consent-text, error.
33
39
  */
34
40
 
35
- export type AuthConsentType = 'legal' | 'marketing' | 'custom';
41
+ export type AuthContactChannel = 'email' | 'sms' | 'call' | 'whatsapp';
42
+
43
+ export type AuthConsentType =
44
+ | 'legal'
45
+ | 'marketing'
46
+ | 'custom'
47
+ | AuthContactChannel;
48
+
49
+ /**
50
+ * The contact channels and the profile flag each one is stored under. The same
51
+ * four flags the register form fans its marketing answer out to.
52
+ */
53
+ export const AUTH_CONTACT_CHANNEL_FLAGS: Record<AuthContactChannel, string> = {
54
+ email: 'email_allowed',
55
+ sms: 'sms_allowed',
56
+ call: 'call_allowed',
57
+ whatsapp: 'whatsapp_allowed'
58
+ };
59
+
60
+ const isContactChannel = (value: string): value is AuthContactChannel =>
61
+ value in AUTH_CONTACT_CHANNEL_FLAGS;
36
62
 
37
63
  /**
38
64
  * What each tick is called on the wire.
@@ -74,7 +100,13 @@ const DEFAULT_TEXT: Record<AuthConsentType, string> = {
74
100
  marketing: 'I would like to receive campaign and marketing messages.',
75
101
  // A custom tick is whatever the brand has to ask this month; nothing here
76
102
  // could guess its sentence.
77
- custom: ''
103
+ custom: '',
104
+ // A channel row is usually one of a list under a heading, so its caption
105
+ // is the channel's name rather than a sentence.
106
+ email: 'E-mail',
107
+ sms: 'SMS',
108
+ call: 'Phone call',
109
+ whatsapp: 'WhatsApp'
78
110
  };
79
111
 
80
112
  const AuthConsentBlock = ({
@@ -90,7 +122,10 @@ const AuthConsentBlock = ({
90
122
  )
91
123
  ).trim();
92
124
  const consentType: AuthConsentType =
93
- rawType === 'marketing' || rawType === 'custom' ? rawType : 'legal';
125
+ rawType === 'marketing' || rawType === 'custom' || isContactChannel(rawType)
126
+ ? rawType
127
+ : 'legal';
128
+ const isChannel = isContactChannel(consentType);
94
129
 
95
130
  /**
96
131
  * The two known ticks own their names because commerce owns their meaning: the
@@ -109,7 +144,9 @@ const AuthConsentBlock = ({
109
144
  const name =
110
145
  consentType === 'custom'
111
146
  ? customName
112
- : AUTH_CONSENT_NAMES[consentType as 'legal' | 'marketing'];
147
+ : isContactChannel(consentType)
148
+ ? AUTH_CONTACT_CHANNEL_FLAGS[consentType]
149
+ : AUTH_CONSENT_NAMES[consentType as 'legal' | 'marketing'];
113
150
 
114
151
  /**
115
152
  * What a ticked custom consent sends. `true` by default because that is what
@@ -117,10 +154,11 @@ const AuthConsentBlock = ({
117
154
  * by typing that instead. (The browser's own default for a valueless checkbox
118
155
  * is `on`, which is why the value is set explicitly rather than left off.)
119
156
  */
120
- const tickedValue =
121
- String(
122
- getResponsiveValue(block.properties?.tickedValue, currentBreakpoint, '')
123
- ).trim() || 'true';
157
+ const tickedValue = isChannel
158
+ ? 'true'
159
+ : String(
160
+ getResponsiveValue(block.properties?.tickedValue, currentBreakpoint, '')
161
+ ).trim() || 'true';
124
162
 
125
163
  const text = String(
126
164
  getResponsiveValue(
@@ -135,18 +173,30 @@ const AuthConsentBlock = ({
135
173
  * because that is what each one IS — but it stays a toggle: a brand whose
136
174
  * agreement is not mandatory in its market must be able to say so without
137
175
  * swapping the block for another.
176
+ *
177
+ * A contact permission is the shopper's choice by definition: a form that
178
+ * refused to save until it was ticked would be extracting consent, not
179
+ * asking for it. So a channel tick is never required, whatever the toggle
180
+ * (which the editor seeds on for every new row) says.
138
181
  */
139
- const required = readToggle(
140
- block.properties?.required,
141
- currentBreakpoint,
142
- consentType === 'legal'
143
- );
182
+ const required =
183
+ !isChannel &&
184
+ readToggle(
185
+ block.properties?.required,
186
+ currentBreakpoint,
187
+ consentType === 'legal'
188
+ );
144
189
 
145
190
  // The form puts its own sentence here — "you have to accept the agreement"
146
191
  // beats "this field is required" for a legal opt-in — and clears it as soon
147
192
  // as the box is ticked.
148
193
  const errorMessage = useAuthFieldError(name);
149
194
  const insideConsentHost = React.useContext(AuthConsentHostContext);
195
+ /**
196
+ * The shopper's stored answer, when the hosting form edits a record (the
197
+ * Profile Form). Unticked everywhere else, which is what a sign-up asks.
198
+ */
199
+ const startsTicked = useFormFieldChecked(name, tickedValue);
150
200
 
151
201
  // Same refusal as an unnamed custom field: a tick that submits nothing is not
152
202
  // a consent, and the canvas has to say what it is waiting for.
@@ -158,8 +208,10 @@ const AuthConsentBlock = ({
158
208
 
159
209
  // A membership or marketing tick outside the form that reads it would post a
160
210
  // string commerce ignores. A CUSTOM tick is the brand's own key, forwarded
161
- // verbatim by any composed form, so it belongs wherever the author puts it.
162
- if (consentType !== 'custom' && !insideConsentHost) {
211
+ // verbatim by any composed form, and a channel tick is a flag both the
212
+ // register and the profile form read by name, so those belong wherever the
213
+ // author puts them.
214
+ if (consentType !== 'custom' && !isChannel && !insideConsentHost) {
163
215
  if (!isDesigner) return null;
164
216
 
165
217
  return (
@@ -177,12 +229,19 @@ const AuthConsentBlock = ({
177
229
  style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}
178
230
  >
179
231
  <input
232
+ // Keyed by name so a tick whose type changes on the canvas (e-mail
233
+ // → WhatsApp) starts again from ITS stored answer: `defaultChecked`
234
+ // is only read when the box mounts.
235
+ key={name}
180
236
  type="checkbox"
181
237
  name={name}
182
- // Only the custom tick carries a value: the two known ones are read
183
- // by presence and re-expressed as flags, so a value on them would be
184
- // a string the form throws away.
185
- value={consentType === 'custom' ? tickedValue : undefined}
238
+ // Only the custom and channel ticks carry a value: the two known ones
239
+ // are read by presence and re-expressed as flags, so a value on them
240
+ // would be a string the form throws away.
241
+ value={
242
+ consentType === 'custom' || isChannel ? tickedValue : undefined
243
+ }
244
+ defaultChecked={startsTicked || undefined}
186
245
  required={required || undefined}
187
246
  aria-invalid={errorMessage ? true : undefined}
188
247
  {...partAttrs('checkbox')}