@akinon/pz-theme 2.0.83-rc.0 → 2.0.84-beta.0
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 +39 -33
- package/package.json +4 -4
- package/src/__tests__/auth-flow-block.test.tsx +211 -0
- package/src/__tests__/auth-flow-bus.test.ts +290 -0
- package/src/__tests__/auth-form-error-block.test.tsx +326 -0
- package/src/__tests__/auth-form-validation.test.tsx +204 -0
- package/src/__tests__/auth-step-title.test.tsx +202 -0
- package/src/__tests__/detached-submit-state.test.tsx +216 -0
- package/src/__tests__/forgot-password-composed.test.tsx +313 -0
- package/src/__tests__/otp-form-composed.test.tsx +487 -0
- package/src/__tests__/password-reset-step.test.tsx +269 -0
- package/src/__tests__/register-form-composed.test.tsx +739 -0
- package/src/__tests__/registry-parity.test.ts +9 -0
- package/src/auth/auth-field-errors.tsx +151 -0
- package/src/auth/auth-flow-bus.ts +415 -0
- package/src/auth/form-error-context.tsx +55 -0
- package/src/auth/form-submit-bus.ts +93 -0
- package/src/auth/otp-form-context.tsx +80 -0
- package/src/blocks/__tests__/button-block.test.tsx +55 -0
- package/src/blocks/__tests__/link-block.test.tsx +48 -30
- package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
- package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
- package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
- package/src/blocks/__tests__/text-block.test.tsx +37 -3
- package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
- package/src/blocks/auth-consent-block.tsx +214 -0
- package/src/blocks/auth-error-block.tsx +82 -0
- package/src/blocks/auth-field-block.tsx +270 -0
- package/src/blocks/auth-flow-block.tsx +251 -0
- package/src/blocks/auth-form-block.tsx +391 -47
- package/src/blocks/auth-step-block.tsx +81 -0
- package/src/blocks/auth-step-title-block.tsx +81 -0
- package/src/blocks/block-renderer-registry.tsx +18 -0
- package/src/blocks/button-block.tsx +91 -13
- package/src/blocks/forgot-password-block.tsx +318 -24
- package/src/blocks/link-block.tsx +22 -84
- package/src/blocks/otp-code-input-block.tsx +180 -0
- package/src/blocks/otp-form-block.tsx +1092 -0
- package/src/blocks/otp-phase-block.tsx +71 -0
- package/src/blocks/register-form-block.tsx +541 -74
- package/src/blocks/text-block.tsx +17 -40
- package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
- package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
- package/src/hooks/use-block-action.ts +87 -4
- package/src/hooks/use-overlay.ts +60 -9
- package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
- package/src/overlays/__tests__/modal-registry.test.ts +6 -1
- package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
- package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
- package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
- package/src/overlays/modal-preview-host.tsx +39 -2
- package/src/overlays/modal-registry.ts +344 -4
- package/src/overlays/overlay-surface.tsx +249 -19
- package/src/overlays/overlay-tokens.ts +94 -0
- package/src/overlays/theme-modal-mount.tsx +34 -4
- package/src/theme-block.tsx +6 -0
- package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
- package/src/utils/index.ts +107 -6
- package/src/utils/iterator-utils.ts +39 -2
- package/src/utils/part-styles.ts +4 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
|
|
5
|
+
import { useAuthFieldError } from '../auth/auth-field-errors';
|
|
6
|
+
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
7
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One field of an auth form, as a block of its own.
|
|
11
|
+
*
|
|
12
|
+
* The monolithic auth form draws its fields in a fixed order the designer
|
|
13
|
+
* cannot touch — its layer-tree "elements" are style parts, not blocks. This
|
|
14
|
+
* block is the composable alternative: dropped INSIDE an Auth Form (or
|
|
15
|
+
* Register Form) it renders a native input whose `name` the form reads
|
|
16
|
+
* through FormData on submit, so the fields, the links between them and the
|
|
17
|
+
* CTA order are all ordinary block arrangement.
|
|
18
|
+
*
|
|
19
|
+
* Purely presentational on purpose: no state, no submit logic — the hosting
|
|
20
|
+
* form owns those. Standalone (outside any form) it still renders, but
|
|
21
|
+
* nothing collects its value, which the designer sees immediately.
|
|
22
|
+
*
|
|
23
|
+
* Properties: fieldType, label, placeholder, showLabel, required, and for
|
|
24
|
+
* a `gender` field the two option captions (femaleLabel, maleLabel).
|
|
25
|
+
* Parts: field, label, input, error.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
type AuthFieldType =
|
|
29
|
+
| 'email'
|
|
30
|
+
| 'password'
|
|
31
|
+
| 'first_name'
|
|
32
|
+
| 'last_name'
|
|
33
|
+
| 'phone'
|
|
34
|
+
| 'gender'
|
|
35
|
+
| 'date_of_birth'
|
|
36
|
+
| 'custom';
|
|
37
|
+
|
|
38
|
+
const FIELD_TYPES: AuthFieldType[] = [
|
|
39
|
+
'email',
|
|
40
|
+
'password',
|
|
41
|
+
'first_name',
|
|
42
|
+
'last_name',
|
|
43
|
+
'phone',
|
|
44
|
+
'gender',
|
|
45
|
+
'date_of_birth',
|
|
46
|
+
'custom'
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What a `custom` field may be, so an author can add a key this list has never
|
|
51
|
+
* heard of — a national id, a company name, a brand's own attribute — without
|
|
52
|
+
* waiting for a release. Everything else about it is an ordinary auth field:
|
|
53
|
+
* the form submits it by name, validates it, and reports its message under it.
|
|
54
|
+
*
|
|
55
|
+
* A short list rather than free text: these are the input types whose built-in
|
|
56
|
+
* validity the form's own check already reads, and a typo'd `type` silently
|
|
57
|
+
* degrades to text.
|
|
58
|
+
*/
|
|
59
|
+
const CUSTOM_INPUT_TYPES = [
|
|
60
|
+
'text',
|
|
61
|
+
'tel',
|
|
62
|
+
'email',
|
|
63
|
+
'number',
|
|
64
|
+
'date',
|
|
65
|
+
'password'
|
|
66
|
+
] as const;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The two values commerce stores for `gender`, which is why they are not
|
|
70
|
+
* authorable: the brand's own profile page posts exactly these strings, so a
|
|
71
|
+
* composition that invented its own would write a value the account page could
|
|
72
|
+
* not read back. Their CAPTIONS are authorable, which is the part a brand
|
|
73
|
+
* actually wants to change (and to translate).
|
|
74
|
+
*/
|
|
75
|
+
const GENDER_VALUES = ['female', 'male'] as const;
|
|
76
|
+
|
|
77
|
+
/** Input semantics per field: type, autocomplete, default caption. */
|
|
78
|
+
const FIELD_PRESETS: Record<
|
|
79
|
+
AuthFieldType,
|
|
80
|
+
{ type: string; autoComplete: string; label: string }
|
|
81
|
+
> = {
|
|
82
|
+
email: { type: 'email', autoComplete: 'email', label: 'E-mail' },
|
|
83
|
+
password: {
|
|
84
|
+
type: 'password',
|
|
85
|
+
autoComplete: 'current-password',
|
|
86
|
+
label: 'Password'
|
|
87
|
+
},
|
|
88
|
+
first_name: {
|
|
89
|
+
type: 'text',
|
|
90
|
+
autoComplete: 'given-name',
|
|
91
|
+
label: 'First name'
|
|
92
|
+
},
|
|
93
|
+
last_name: { type: 'text', autoComplete: 'family-name', label: 'Last name' },
|
|
94
|
+
phone: { type: 'tel', autoComplete: 'tel', label: 'Phone' },
|
|
95
|
+
// A native date control rather than a masked text box: its value is already
|
|
96
|
+
// the `YYYY-MM-DD` commerce stores, and the browser shows the day/month/year
|
|
97
|
+
// order of the shopper's own locale — which is what a placeholder like
|
|
98
|
+
// "GG/AA/YYYY" was trying to say by hand.
|
|
99
|
+
date_of_birth: { type: 'date', autoComplete: 'bday', label: 'Birth date' },
|
|
100
|
+
// Rendered as a select rather than an input; the type is unused for it.
|
|
101
|
+
gender: { type: 'text', autoComplete: 'sex', label: 'Gender' },
|
|
102
|
+
// The author names it and picks its type; nothing here can guess either.
|
|
103
|
+
custom: { type: 'text', autoComplete: 'off', label: 'Field' }
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const AuthFieldBlock = ({
|
|
107
|
+
block,
|
|
108
|
+
currentBreakpoint = 'desktop',
|
|
109
|
+
isDesigner = false
|
|
110
|
+
}: BlockRendererProps) => {
|
|
111
|
+
const prop = (key: string, fallback: string) =>
|
|
112
|
+
String(
|
|
113
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const fieldType = resolveOption<AuthFieldType>(
|
|
117
|
+
block.properties?.fieldType,
|
|
118
|
+
currentBreakpoint,
|
|
119
|
+
'email',
|
|
120
|
+
FIELD_TYPES
|
|
121
|
+
);
|
|
122
|
+
const preset = FIELD_PRESETS[fieldType];
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What the form will call this value.
|
|
126
|
+
*
|
|
127
|
+
* For every known field the type IS the name — `email` submits `email` — which
|
|
128
|
+
* is what lets a form validate and message by field without being told. A
|
|
129
|
+
* `custom` field has no such name until the author gives it one, and an input
|
|
130
|
+
* without a name submits nothing at all, so an unnamed one is refused below
|
|
131
|
+
* rather than drawn as a control that quietly collects into the void.
|
|
132
|
+
*/
|
|
133
|
+
const customName = prop('name', '').trim();
|
|
134
|
+
const controlName = fieldType === 'custom' ? customName : fieldType;
|
|
135
|
+
const controlType =
|
|
136
|
+
fieldType === 'custom'
|
|
137
|
+
? resolveOption<(typeof CUSTOM_INPUT_TYPES)[number]>(
|
|
138
|
+
block.properties?.inputType,
|
|
139
|
+
currentBreakpoint,
|
|
140
|
+
'text',
|
|
141
|
+
CUSTOM_INPUT_TYPES as unknown as (typeof CUSTOM_INPUT_TYPES)[number][]
|
|
142
|
+
)
|
|
143
|
+
: preset.type;
|
|
144
|
+
|
|
145
|
+
const label = prop('label', preset.label);
|
|
146
|
+
const placeholder = prop('placeholder', '');
|
|
147
|
+
const showLabel = readToggle(
|
|
148
|
+
block.properties?.showLabel,
|
|
149
|
+
currentBreakpoint,
|
|
150
|
+
true
|
|
151
|
+
);
|
|
152
|
+
const required = readToggle(
|
|
153
|
+
block.properties?.required,
|
|
154
|
+
currentBreakpoint,
|
|
155
|
+
true
|
|
156
|
+
);
|
|
157
|
+
/**
|
|
158
|
+
* What the surrounding form found wrong about THIS field on its last submit.
|
|
159
|
+
* The form validates (it owns submit) and publishes the messages by control
|
|
160
|
+
* name; a field standing outside any form simply never has one.
|
|
161
|
+
*/
|
|
162
|
+
const errorMessage = useAuthFieldError(controlName);
|
|
163
|
+
|
|
164
|
+
/** Authorable captions for a fixed pair of stored values. */
|
|
165
|
+
const femaleLabel = prop('femaleLabel', 'Female');
|
|
166
|
+
const maleLabel = prop('maleLabel', 'Male');
|
|
167
|
+
|
|
168
|
+
// One declaration for both controls: a select that does not look like the
|
|
169
|
+
// inputs beside it reads as a different form.
|
|
170
|
+
const controlStyle: React.CSSProperties = {
|
|
171
|
+
width: '100%',
|
|
172
|
+
border: `1px solid ${
|
|
173
|
+
errorMessage
|
|
174
|
+
? 'var(--auth-field-border-invalid, #dc2626)'
|
|
175
|
+
: 'var(--auth-field-border, #d1d5db)'
|
|
176
|
+
}`,
|
|
177
|
+
background: 'transparent',
|
|
178
|
+
color: 'inherit',
|
|
179
|
+
padding: '10px 12px',
|
|
180
|
+
outline: 'none'
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// A custom field with no name collects nothing, so a shopper must not be
|
|
184
|
+
// given it to fill in — but the author has to be told what it is waiting for,
|
|
185
|
+
// or dropping the block looks like nothing happened.
|
|
186
|
+
if (!controlName) {
|
|
187
|
+
if (!isDesigner) return null;
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<DesignerPlaceholder label="Custom field (give this field a Name)" />
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<label
|
|
196
|
+
data-auth-field={controlName}
|
|
197
|
+
// Fills its block wrapper; see the note in auth-step-block.tsx. In a row
|
|
198
|
+
// Group two fields still share the row — a flex item shrinks from 100%.
|
|
199
|
+
className="w-full"
|
|
200
|
+
{...partAttrs('field')}
|
|
201
|
+
style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
|
|
202
|
+
>
|
|
203
|
+
{/* Hiding the caption must not cost the input its accessible name, so
|
|
204
|
+
the label text moves onto aria-label instead of disappearing. */}
|
|
205
|
+
{showLabel && <span {...partAttrs('label')}>{label}</span>}
|
|
206
|
+
{fieldType === 'gender' ? (
|
|
207
|
+
/**
|
|
208
|
+
* A select, because the answer is one of two stored values rather than
|
|
209
|
+
* free text — and it carries the SAME `data-part="input"` as every other
|
|
210
|
+
* field's control, so a brand's border, height and focus ring reach it
|
|
211
|
+
* without a second set of controls in the inspector.
|
|
212
|
+
*
|
|
213
|
+
* The empty first option is what makes "not answered" expressible: with
|
|
214
|
+
* `required` off a shopper may leave it, and with `required` on the form
|
|
215
|
+
* reports it like any other empty field.
|
|
216
|
+
*/
|
|
217
|
+
<select
|
|
218
|
+
name={controlName}
|
|
219
|
+
autoComplete={preset.autoComplete}
|
|
220
|
+
defaultValue=""
|
|
221
|
+
aria-label={showLabel ? undefined : label}
|
|
222
|
+
aria-invalid={errorMessage ? true : undefined}
|
|
223
|
+
required={required}
|
|
224
|
+
{...partAttrs('input')}
|
|
225
|
+
style={controlStyle}
|
|
226
|
+
>
|
|
227
|
+
<option value="">{placeholder || label}</option>
|
|
228
|
+
{GENDER_VALUES.map((value) => (
|
|
229
|
+
<option key={value} value={value}>
|
|
230
|
+
{value === 'female' ? femaleLabel : maleLabel}
|
|
231
|
+
</option>
|
|
232
|
+
))}
|
|
233
|
+
</select>
|
|
234
|
+
) : (
|
|
235
|
+
<input
|
|
236
|
+
type={controlType}
|
|
237
|
+
name={controlName}
|
|
238
|
+
autoComplete={preset.autoComplete}
|
|
239
|
+
placeholder={placeholder || undefined}
|
|
240
|
+
aria-label={showLabel ? undefined : label}
|
|
241
|
+
aria-invalid={errorMessage ? true : undefined}
|
|
242
|
+
// `required` stays on the control even though the form runs the check
|
|
243
|
+
// itself: it IS the check. `noValidate` up on the form suppresses the
|
|
244
|
+
// browser's bubble, not its `validity` reporting.
|
|
245
|
+
required={required}
|
|
246
|
+
{...partAttrs('input')}
|
|
247
|
+
style={controlStyle}
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
250
|
+
{/* Under the field and in the flow, so the layout makes room for it
|
|
251
|
+
instead of the message floating over the next control — which is the
|
|
252
|
+
half of the native bubble no brand ever wanted. */}
|
|
253
|
+
{errorMessage && (
|
|
254
|
+
<span
|
|
255
|
+
data-auth-field-error={controlName}
|
|
256
|
+
{...partAttrs('error')}
|
|
257
|
+
role="alert"
|
|
258
|
+
style={{
|
|
259
|
+
color: 'var(--auth-field-error-color, #dc2626)',
|
|
260
|
+
fontSize: '0.875em'
|
|
261
|
+
}}
|
|
262
|
+
>
|
|
263
|
+
{errorMessage}
|
|
264
|
+
</span>
|
|
265
|
+
)}
|
|
266
|
+
</label>
|
|
267
|
+
);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
export default AuthFieldBlock;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect } from 'react';
|
|
4
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
5
|
+
import {
|
|
6
|
+
ActiveAuthStepContext,
|
|
7
|
+
AuthFlowIdContext,
|
|
8
|
+
DEFAULT_AUTH_FLOW_ID,
|
|
9
|
+
goBackAuthStep,
|
|
10
|
+
registerAuthFlowMount,
|
|
11
|
+
useAuthFlowState
|
|
12
|
+
} from '../auth/auth-flow-bus';
|
|
13
|
+
import { Block } from '../theme-block';
|
|
14
|
+
import { renderComposedChildren } from './render-composed-children';
|
|
15
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
16
|
+
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A step-by-step auth journey assembled from `auth-step` children.
|
|
20
|
+
*
|
|
21
|
+
* The flow renders exactly one step at a time and any non-step children
|
|
22
|
+
* always (a shared heading above the journey, say). Which step is active
|
|
23
|
+
* lives on the auth-flow bus, so ANYTHING can drive it: a button inside a
|
|
24
|
+
* step (`actionType: 'auth-step'`, resolved to this flow through context), a
|
|
25
|
+
* header trigger in another placeholder (`actionTarget: 'auth:register'`), or
|
|
26
|
+
* the register form jumping to its verification step. The flow itself owns
|
|
27
|
+
* only the fallback (its authored initial step) and the optional back
|
|
28
|
+
* control.
|
|
29
|
+
*
|
|
30
|
+
* Nothing here is auth-specific beyond the name: the steps' contents are
|
|
31
|
+
* whatever blocks the author composed, which is what lets the same flow be a
|
|
32
|
+
* right-hand drawer on one brand and an inline page area on another.
|
|
33
|
+
*
|
|
34
|
+
* In the designer the bus is inert, so the canvas shows the step that owns
|
|
35
|
+
* the current selection — clicking a step (or anything inside one) in the
|
|
36
|
+
* layer tree brings that step up — then the author's `previewStep`, then the
|
|
37
|
+
* initial step.
|
|
38
|
+
*
|
|
39
|
+
* Properties: flowId, initialStep, showBack, backLabel, previewStep,
|
|
40
|
+
* startFresh.
|
|
41
|
+
* Parts: flow, back.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const containsBlockId = (
|
|
45
|
+
blocks: Block[] | undefined,
|
|
46
|
+
blockId: string
|
|
47
|
+
): boolean =>
|
|
48
|
+
(blocks || []).some(
|
|
49
|
+
(candidate) =>
|
|
50
|
+
candidate.id === blockId || containsBlockId(candidate.blocks, blockId)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/** A step with no authored key still needs a stable one for matching. */
|
|
54
|
+
const stepTitleOf = (step: Block, currentBreakpoint: string): string =>
|
|
55
|
+
String(
|
|
56
|
+
getResponsiveValue(step.properties?.title, currentBreakpoint, '')
|
|
57
|
+
).trim();
|
|
58
|
+
|
|
59
|
+
const stepKeyOf = (step: Block, currentBreakpoint: string): string =>
|
|
60
|
+
String(
|
|
61
|
+
getResponsiveValue(step.properties?.stepKey, currentBreakpoint, '')
|
|
62
|
+
).trim() || step.id;
|
|
63
|
+
|
|
64
|
+
const AuthFlowBlock = ({
|
|
65
|
+
block,
|
|
66
|
+
placeholderId,
|
|
67
|
+
sectionId,
|
|
68
|
+
isDesigner = false,
|
|
69
|
+
selectedBlockId,
|
|
70
|
+
currentBreakpoint = 'desktop'
|
|
71
|
+
}: BlockRendererProps) => {
|
|
72
|
+
const prop = (key: string, fallback: string) =>
|
|
73
|
+
String(
|
|
74
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const flowId = prop('flowId', '').trim() || DEFAULT_AUTH_FLOW_ID;
|
|
78
|
+
const initialStep = prop('initialStep', '').trim();
|
|
79
|
+
const previewStep = prop('previewStep', '').trim();
|
|
80
|
+
const backLabel = prop('backLabel', 'Back');
|
|
81
|
+
const showBack = readToggle(
|
|
82
|
+
block.properties?.showBack,
|
|
83
|
+
currentBreakpoint,
|
|
84
|
+
false
|
|
85
|
+
);
|
|
86
|
+
// On: a flow that leaves the page (a drawer closing unmounts its content)
|
|
87
|
+
// forgets the abandoned journey and reopens on its initial step.
|
|
88
|
+
const startFresh = readToggle(
|
|
89
|
+
block.properties?.startFresh,
|
|
90
|
+
currentBreakpoint,
|
|
91
|
+
true
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const flowState = useAuthFlowState(isDesigner ? '' : flowId);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (isDesigner) return undefined;
|
|
98
|
+
|
|
99
|
+
return registerAuthFlowMount(flowId, { resetOnLastUnmount: startFresh });
|
|
100
|
+
}, [isDesigner, flowId, startFresh]);
|
|
101
|
+
|
|
102
|
+
const childBlocks = (block.blocks || [])
|
|
103
|
+
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
104
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
105
|
+
|
|
106
|
+
const steps = childBlocks.filter(
|
|
107
|
+
(childBlock) => childBlock.type === 'auth-step'
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const stepKeys = steps.map((step) => stepKeyOf(step, currentBreakpoint));
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Which step to show. Every candidate is validated against the authored
|
|
114
|
+
* steps — a stale bus entry (a step the author renamed away) or a typo'd
|
|
115
|
+
* initial step must fall through, not blank the flow.
|
|
116
|
+
*/
|
|
117
|
+
const resolveActiveKey = (): string => {
|
|
118
|
+
if (isDesigner && selectedBlockId) {
|
|
119
|
+
const selected = steps.find(
|
|
120
|
+
(step) =>
|
|
121
|
+
step.id === selectedBlockId ||
|
|
122
|
+
containsBlockId(step.blocks, selectedBlockId)
|
|
123
|
+
);
|
|
124
|
+
if (selected) return stepKeyOf(selected, currentBreakpoint);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const candidates = isDesigner
|
|
128
|
+
? [previewStep, initialStep]
|
|
129
|
+
: [flowState?.active ?? '', initialStep];
|
|
130
|
+
|
|
131
|
+
for (const candidate of candidates) {
|
|
132
|
+
if (candidate && stepKeys.includes(candidate)) return candidate;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return stepKeys[0] ?? '';
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const activeKey = resolveActiveKey();
|
|
139
|
+
|
|
140
|
+
if (steps.length === 0 && !isDesigner) return null;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The children, with the six editor callbacks each one needs to be movable,
|
|
144
|
+
* duplicable, hideable, deletable and renamable on the canvas.
|
|
145
|
+
*
|
|
146
|
+
* Through the package's own `renderComposedChildren` rather than a private
|
|
147
|
+
* copy of it. The copies each posted their messages to `'*'`, which
|
|
148
|
+
* `editor-bridge.ts` exists to prevent — it addresses the editor's real
|
|
149
|
+
* origin, so an editor command never reaches whatever other page happens to
|
|
150
|
+
* be embedding the storefront — and every copy was a place a new message
|
|
151
|
+
* field had to be remembered.
|
|
152
|
+
*/
|
|
153
|
+
const renderChild = (childBlock: Block) =>
|
|
154
|
+
renderComposedChildren({
|
|
155
|
+
blocks: [childBlock],
|
|
156
|
+
placeholderId,
|
|
157
|
+
sectionId,
|
|
158
|
+
isDesigner,
|
|
159
|
+
selectedBlockId,
|
|
160
|
+
currentBreakpoint
|
|
161
|
+
})[0] ?? null;
|
|
162
|
+
|
|
163
|
+
// Shown on the storefront only once there is somewhere to go back TO; in the
|
|
164
|
+
// canvas it shows whenever enabled, so it can be styled without navigating.
|
|
165
|
+
const canGoBack = isDesigner
|
|
166
|
+
? showBack
|
|
167
|
+
: showBack &&
|
|
168
|
+
Boolean(
|
|
169
|
+
flowState && (flowState.history.length > 0 || initialStep !== activeKey)
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const backButton = canGoBack ? (
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
data-auth-flow-back=""
|
|
176
|
+
{...partAttrs('back')}
|
|
177
|
+
onClick={() => {
|
|
178
|
+
if (isDesigner) return;
|
|
179
|
+
goBackAuthStep(flowId);
|
|
180
|
+
}}
|
|
181
|
+
style={{
|
|
182
|
+
alignSelf: 'flex-start',
|
|
183
|
+
padding: 0,
|
|
184
|
+
margin: 0,
|
|
185
|
+
border: 'none',
|
|
186
|
+
background: 'transparent',
|
|
187
|
+
color: 'inherit',
|
|
188
|
+
font: 'inherit',
|
|
189
|
+
cursor: 'pointer',
|
|
190
|
+
textDecoration: 'underline'
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
{backLabel}
|
|
194
|
+
</button>
|
|
195
|
+
) : null;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* What the shared title row renders. Derived here because this is where the
|
|
199
|
+
* active step is decided — a row that is a SIBLING of the steps could never
|
|
200
|
+
* work it out for itself.
|
|
201
|
+
*/
|
|
202
|
+
const activeStepBlock = steps.find(
|
|
203
|
+
(step) => stepKeyOf(step, currentBreakpoint) === activeKey
|
|
204
|
+
);
|
|
205
|
+
const activeStep = {
|
|
206
|
+
key: activeKey,
|
|
207
|
+
title: activeStepBlock
|
|
208
|
+
? stepTitleOf(activeStepBlock, currentBreakpoint)
|
|
209
|
+
: ''
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<AuthFlowIdContext.Provider value={flowId}>
|
|
214
|
+
<ActiveAuthStepContext.Provider value={activeStep}>
|
|
215
|
+
<div
|
|
216
|
+
data-auth-flow=""
|
|
217
|
+
// Fills its block wrapper; see the note in auth-step-block.tsx.
|
|
218
|
+
className="w-full"
|
|
219
|
+
data-auth-flow-id={flowId}
|
|
220
|
+
data-auth-flow-step={activeKey || undefined}
|
|
221
|
+
{...partAttrs('flow')}
|
|
222
|
+
style={{
|
|
223
|
+
display: 'flex',
|
|
224
|
+
flexDirection: 'column',
|
|
225
|
+
gap: 'var(--auth-flow-gap, 16px)'
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
{backButton}
|
|
229
|
+
|
|
230
|
+
{childBlocks.length === 0 ? (
|
|
231
|
+
<DesignerPlaceholder label="Auth flow (add an Auth Step per screen)" />
|
|
232
|
+
) : (
|
|
233
|
+
childBlocks.map((childBlock) => {
|
|
234
|
+
if (childBlock.type !== 'auth-step') {
|
|
235
|
+
return renderChild(childBlock);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (stepKeyOf(childBlock, currentBreakpoint) !== activeKey) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return renderChild(childBlock);
|
|
243
|
+
})
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
</ActiveAuthStepContext.Provider>
|
|
247
|
+
</AuthFlowIdContext.Provider>
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export default AuthFlowBlock;
|