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

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 (63) hide show
  1. package/CHANGELOG.md +31 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-flow-block.test.tsx +211 -0
  4. package/src/__tests__/auth-flow-bus.test.ts +290 -0
  5. package/src/__tests__/auth-form-error-block.test.tsx +326 -0
  6. package/src/__tests__/auth-form-validation.test.tsx +204 -0
  7. package/src/__tests__/auth-step-title.test.tsx +202 -0
  8. package/src/__tests__/detached-submit-state.test.tsx +216 -0
  9. package/src/__tests__/forgot-password-composed.test.tsx +313 -0
  10. package/src/__tests__/otp-form-composed.test.tsx +487 -0
  11. package/src/__tests__/password-reset-step.test.tsx +269 -0
  12. package/src/__tests__/register-form-composed.test.tsx +739 -0
  13. package/src/__tests__/registry-parity.test.ts +9 -0
  14. package/src/auth/auth-field-errors.tsx +151 -0
  15. package/src/auth/auth-flow-bus.ts +415 -0
  16. package/src/auth/form-error-context.tsx +55 -0
  17. package/src/auth/form-submit-bus.ts +93 -0
  18. package/src/auth/otp-form-context.tsx +80 -0
  19. package/src/blocks/__tests__/button-block.test.tsx +55 -0
  20. package/src/blocks/__tests__/link-block.test.tsx +48 -30
  21. package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
  22. package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
  23. package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
  24. package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
  25. package/src/blocks/__tests__/text-block.test.tsx +37 -3
  26. package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
  27. package/src/blocks/auth-consent-block.tsx +214 -0
  28. package/src/blocks/auth-error-block.tsx +82 -0
  29. package/src/blocks/auth-field-block.tsx +270 -0
  30. package/src/blocks/auth-flow-block.tsx +251 -0
  31. package/src/blocks/auth-form-block.tsx +391 -47
  32. package/src/blocks/auth-step-block.tsx +81 -0
  33. package/src/blocks/auth-step-title-block.tsx +81 -0
  34. package/src/blocks/block-renderer-registry.tsx +18 -0
  35. package/src/blocks/button-block.tsx +91 -13
  36. package/src/blocks/forgot-password-block.tsx +318 -24
  37. package/src/blocks/link-block.tsx +22 -84
  38. package/src/blocks/otp-code-input-block.tsx +180 -0
  39. package/src/blocks/otp-form-block.tsx +1092 -0
  40. package/src/blocks/otp-phase-block.tsx +71 -0
  41. package/src/blocks/register-form-block.tsx +541 -74
  42. package/src/blocks/text-block.tsx +17 -40
  43. package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
  44. package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
  45. package/src/hooks/use-block-action.ts +87 -4
  46. package/src/hooks/use-overlay.ts +60 -9
  47. package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
  48. package/src/overlays/__tests__/modal-registry.test.ts +6 -1
  49. package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
  50. package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
  51. package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
  52. package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
  53. package/src/overlays/modal-preview-host.tsx +39 -2
  54. package/src/overlays/modal-registry.ts +344 -4
  55. package/src/overlays/overlay-surface.tsx +249 -19
  56. package/src/overlays/overlay-tokens.ts +94 -0
  57. package/src/overlays/theme-modal-mount.tsx +34 -4
  58. package/src/theme-block.tsx +6 -0
  59. package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
  60. package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
  61. package/src/utils/index.ts +107 -6
  62. package/src/utils/iterator-utils.ts +39 -2
  63. package/src/utils/part-styles.ts +4 -0
package/CHANGELOG.md CHANGED
@@ -1,38 +1,36 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.83-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b]
34
- - @akinon/next@2.0.83-rc.0
35
- - @akinon/theme-contract@2.0.83-rc.0
3
+ ## 2.0.83
4
+
5
+ ### Patch Changes
6
+
7
+ - 5d4597c9: ZERO-4664: Let a brand add a field or an agreement the theme has never heard of without a release — an auth field can be a Custom one, named with the key the backend expects and given the input type it needs, and a consent row can be a Custom tick with its own name and the value it sends when ticked; the composed forms forward both exactly as authored, and an unnamed one is refused rather than drawn as a control that collects nothing
8
+ - 5d4597c9: ZERO-4664: Let a sign-up ask for a gender and a birth date — the gender field is a select of the two values commerce stores with captions a brand writes in its own language, and the birth date is a native date control whose value is already the format the account keeps
9
+ - a307a5b6: ZERO-4664: Compose the auth journey out of blocks — an auth flow of named steps, a password-reset step, auth fields and OTP phases with their own code boxes — and give every auth form its own inline validation messages, a detached submit CTA that greys out while the form works, and a full-width root so a drawer's composition fills the panel
10
+ - 5d4597c9: ZERO-4664: Let a composition say where an auth form's error message goes — a Form Error block draws it in the design's own layout, so a failed login no longer writes its message below the button that was just pressed and flush against the panel edge, and a composition that has not placed one still gets the form's built-in line
11
+ - 892e1a6a: ZERO-4664: Give an auth journey one shared title row — each step carries its own title and a Step Title block renders whichever step the flow is showing, so a drawer keeps one close button and one heading row instead of repeating both in every step
12
+ - d723a217: ZERO-4664: Let a modal place and size its built-in close button, so the ✕ can line up with a title row authored in the composition instead of sitting at a fixed corner offset it had no way to leave
13
+ - 73a47d01: ZERO-4664: Keep a composed modal's close button clickable — a full-width block in the panel, such as a shared title row, was painting over the ✕ and swallowing every click on it
14
+ - b66c61b0: ZERO-4664: Post the body a composition means — an optional answer nobody gave is left out instead of travelling as an empty string, a field named after part of the protocol is ignored rather than allowed to change which endpoint is called or whether next-auth answers at all, and where a name is duplicated the first control wins, which is the rule the validation beside it already used and which keeps an empty twin from erasing what the shopper typed
15
+ - b66c61b0: ZERO-4664: Send the auth blocks' editor messages through the package's own bridge, which addresses the editor's real origin, instead of six private copies of the same loop posting to any window that happens to be embedding the storefront
16
+ - b66c61b0: ZERO-4664: Let a Consent Row say where it works — the membership and marketing ticks are questions the Register Form answers, so outside it they posted a string commerce does not read as a consent while looking to the author like a working row; a Custom tick is the brand's own key and still belongs anywhere
17
+ - 7345ba25: ZERO-4664: Let a password reset be composed the way a login already could — the Forgot Password form draws the children a design authors (an Auth Field for the address, a Form Error block for the message, its own CTA) and requests the reset for exactly the address the fixed row did, so a brand can put a sentence above the input, move the message out from under the button and hand the submit to a drawer's pinned CTA; and the state after the mail is away can be a step of the flow, authored out of ordinary blocks, instead of one message and one link
18
+ - b66c61b0: ZERO-4664: Stop a control the design has taken off this breakpoint from holding the form shut — the editor's Visibility setting is CSS, so a required field hidden on mobile stayed in the form and refused every submit there with its explanation drawn inside the hidden wrapper; and a Form Error block hidden inside a container no longer counts as the place the message goes, which was silencing the form's own message as well as itself
19
+ - f4e4b678: ZERO-4664: Apply a text, link or button block's own styles once — the renderers that draw their own root now let the generated stylesheet target that element instead of inlining the same declarations over the wrapper's, so a padding of 24 is 24 rather than 48 and a mobile override or a hover can still win
20
+ - 17d509e3: ZERO-4664: Honour a modal's Content Padding and Stretch Content settings — the config parser now reads both, so a panel padded to 0 renders at 0 instead of keeping the historical 24, and a drawer set to stretch hands its full height to the composition so a CTA can sit on the bottom edge
21
+ - 617b8c14: ZERO-4664: Let a sheet's height and a modal's backdrop be answered per breakpoint, so a phone can get a 92% sheet behind a heavier backdrop while the desktop keeps its own values, and make the grab bar a sheet draws at its top centre optional for compositions that bring their own header
22
+ - 0c1c0b8c: ZERO-4664: Let a modal arrive as a different shape on narrower viewports — a centred dialog on a desktop can be a bottom sheet on a phone — and give a stretched sheet a real height, so its composition can pin a CTA to the bottom edge
23
+ - b66c61b0: ZERO-4664: Honour what a Consent Row says about itself — a tick the author marked optional can be left unticked and the shopper's actual answer travels, and a composition that asks no agreement at all registers the way a fixed form does instead of refusing every submit over a sentence that was nowhere on screen
24
+ - b66c61b0: ZERO-4664: Let a shopper fix a digit they got wrong — a code box now writes where it was typed instead of appending to the end (on a full code the keystroke did nothing at all, and on a half-typed one the box's own digit came back with the new one and both landed at the end), Backspace empties the box it was pressed in rather than the last one, and a code with a gap in it is not treated as finished
25
+ - 90213d4e: ZERO-4664: Let the OTP form be the only place a verification code's length is set — the code boxes no longer carry a box-count setting of their own, which inside a form could only sit at a number the boxes ignored and read as broken
26
+ - e0d5f394: ZERO-4664: Keep the page still for as long as an overlay covers it — the scroll lock is now a rule that the inline styles other components write cannot outrank, so a step of an auth journey arriving in a drawer no longer hands the site its scrollbar back mid-flow, and a button stops carrying a closed dialog that reached for the page's scrolling for no reason
27
+ - efa3b962: ZERO-4664: Let a modal keep the site header clear — the overlay layer, backdrop included, can start below it, so a bottom sheet no longer rides over a header the design leaves visible and clickable
28
+ - b66c61b0: ZERO-4664: Give a parked SMS verification an owner, so one page-wide slot stops being everybody's — a record now belongs to the journey that started it and to the step it moved to, an OTP form finishes only its own, navigating away from that step abandons it, a flow's cleanup clears its own journey instead of whichever verification the page happened to be carrying, and nothing outlives the visit: a registration waiting on a code holds a password until it is verified, and a page with no flow to unmount had no way to let go of it
29
+ - 5d4597c9: ZERO-4664: Let a registration be composed the way a login already could — the Register Form draws the children a design authors (a field per input, a Consent Row per tick, its own CTA and message) and posts exactly the body its fixed rows posted, so a brand can reorder, relabel and rewrite every row of a sign-up, the legal consent sentence included, instead of accepting the form's own
30
+ - b66c61b0: ZERO-4664: Make the two controls beside a code behave — a resend cannot fire again while its request is still in flight (the cooldown only restarted once the answer came back, so the whole round trip was a second SMS waiting to happen), a refused resend says so instead of restarting the wait and wiping the digits already typed, and "use a different number" no longer throws away a registration whose parked payload is the only copy of it
31
+ - b19a42f3: ZERO-4664: Let a sheet's height be written as a CSS length rather than only picked as a percentage, so a drawer can be calc(100dvh - 172px) — everything below the header — or fit-content, as tall as its composition needs
32
+ - @akinon/next@2.0.83
33
+ - @akinon/theme-contract@2.0.83
36
34
 
37
35
  ## 2.0.82
38
36
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.83-rc.0",
4
+ "version": "2.0.83",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.83-rc.0",
11
+ "@akinon/next": "2.0.83",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.83-rc.0",
16
+ "@akinon/theme-contract": "2.0.83",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.83-rc.0",
23
+ "@akinon/next": "2.0.83",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,211 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import AuthFlowBlock from '../blocks/auth-flow-block';
5
+ import {
6
+ __resetAuthFlowBusForTests,
7
+ AuthFlowIdContext,
8
+ getAuthFlowState,
9
+ setAuthStep
10
+ } from '../auth/auth-flow-bus';
11
+ import { useBlockAction } from '../hooks/use-block-action';
12
+ import { Block } from '../theme-block';
13
+
14
+ const textChild = (id: string, value: string): Block => ({
15
+ id,
16
+ type: 'text',
17
+ label: id,
18
+ value,
19
+ properties: {},
20
+ styles: {},
21
+ order: 0,
22
+ hidden: false
23
+ });
24
+
25
+ const step = (
26
+ id: string,
27
+ stepKey: string,
28
+ children: Block[],
29
+ order = 0
30
+ ): Block => ({
31
+ id,
32
+ type: 'auth-step',
33
+ label: id,
34
+ properties: { stepKey },
35
+ styles: {},
36
+ order,
37
+ hidden: false,
38
+ blocks: children
39
+ });
40
+
41
+ const flowBlock = (
42
+ properties: Record<string, unknown>,
43
+ steps: Block[]
44
+ ): Block => ({
45
+ id: 'flow-1',
46
+ type: 'auth-flow',
47
+ label: 'Auth Flow',
48
+ properties,
49
+ styles: {},
50
+ order: 0,
51
+ hidden: false,
52
+ blocks: steps
53
+ });
54
+
55
+ const renderFlow = (
56
+ properties: Record<string, unknown>,
57
+ options: { isDesigner?: boolean; selectedBlockId?: string | null } = {}
58
+ ) =>
59
+ render(
60
+ <AuthFlowBlock
61
+ block={flowBlock(properties, [
62
+ step('step-welcome', 'welcome', [textChild('t-welcome', 'Welcome copy')], 0),
63
+ step('step-login', 'login', [textChild('t-login', 'Login copy')], 1),
64
+ step('step-register', 'register', [textChild('t-register', 'Register copy')], 2)
65
+ ])}
66
+ placeholderId="body"
67
+ sectionId="section-1"
68
+ isDesigner={options.isDesigner ?? false}
69
+ selectedBlockId={options.selectedBlockId ?? null}
70
+ />
71
+ );
72
+
73
+ describe('auth flow block', () => {
74
+ beforeEach(() => {
75
+ __resetAuthFlowBusForTests();
76
+ });
77
+
78
+ it('renders only its initial step', () => {
79
+ renderFlow({ flowId: 'auth', initialStep: 'welcome' });
80
+
81
+ expect(screen.getByText('Welcome copy')).toBeInTheDocument();
82
+ expect(screen.queryByText('Login copy')).toBeNull();
83
+ expect(screen.queryByText('Register copy')).toBeNull();
84
+ });
85
+
86
+ it('follows the bus, which is how triggers anywhere on the page drive it', () => {
87
+ renderFlow({ flowId: 'auth', initialStep: 'welcome' });
88
+
89
+ act(() => {
90
+ setAuthStep('auth', 'register');
91
+ });
92
+
93
+ expect(screen.queryByText('Welcome copy')).toBeNull();
94
+ expect(screen.getByText('Register copy')).toBeInTheDocument();
95
+ });
96
+
97
+ it('falls back to the initial step when the bus names a step the author removed', () => {
98
+ renderFlow({ flowId: 'auth', initialStep: 'welcome' });
99
+
100
+ act(() => {
101
+ setAuthStep('auth', 'no-such-step');
102
+ });
103
+
104
+ expect(screen.getByText('Welcome copy')).toBeInTheDocument();
105
+ });
106
+
107
+ it('walks back through its own back control', () => {
108
+ renderFlow({
109
+ flowId: 'auth',
110
+ initialStep: 'welcome',
111
+ showBack: true,
112
+ backLabel: 'Go back'
113
+ });
114
+
115
+ act(() => {
116
+ setAuthStep('auth', 'login');
117
+ });
118
+ act(() => {
119
+ setAuthStep('auth', 'register');
120
+ });
121
+
122
+ fireEvent.click(screen.getByText('Go back'));
123
+ expect(screen.getByText('Login copy')).toBeInTheDocument();
124
+
125
+ fireEvent.click(screen.getByText('Go back'));
126
+ expect(screen.getByText('Welcome copy')).toBeInTheDocument();
127
+ });
128
+
129
+ it('shows the designer the step that owns the current selection', () => {
130
+ renderFlow(
131
+ { flowId: 'auth', initialStep: 'welcome' },
132
+ { isDesigner: true, selectedBlockId: 't-register' }
133
+ );
134
+
135
+ expect(screen.getByText('Register copy')).toBeInTheDocument();
136
+ expect(screen.queryByText('Welcome copy')).toBeNull();
137
+ });
138
+
139
+ it('lets the designer pin a step with previewStep when nothing is selected', () => {
140
+ renderFlow(
141
+ { flowId: 'auth', initialStep: 'welcome', previewStep: 'login' },
142
+ { isDesigner: true }
143
+ );
144
+
145
+ expect(screen.getByText('Login copy')).toBeInTheDocument();
146
+ });
147
+ });
148
+
149
+ describe('auth-step block action', () => {
150
+ beforeEach(() => {
151
+ __resetAuthFlowBusForTests();
152
+ });
153
+
154
+ const Trigger = ({
155
+ properties
156
+ }: {
157
+ properties: Record<string, unknown>;
158
+ }) => {
159
+ const action = useBlockAction({ properties });
160
+
161
+ return (
162
+ <button type="button" onClick={action.onClick}>
163
+ go
164
+ </button>
165
+ );
166
+ };
167
+
168
+ it('resolves a bare step key against the flow it sits inside', () => {
169
+ render(
170
+ <AuthFlowIdContext.Provider value="drawer">
171
+ <Trigger
172
+ properties={{ actionType: 'auth-step', actionTarget: 'register' }}
173
+ />
174
+ </AuthFlowIdContext.Provider>
175
+ );
176
+
177
+ fireEvent.click(screen.getByText('go'));
178
+
179
+ expect(getAuthFlowState('drawer')?.active).toBe('register');
180
+ });
181
+
182
+ it('reaches a named flow from outside every flow — the header-button case', () => {
183
+ render(
184
+ <Trigger
185
+ properties={{
186
+ actionType: 'auth-step',
187
+ actionTarget: 'drawer:register'
188
+ }}
189
+ />
190
+ );
191
+
192
+ fireEvent.click(screen.getByText('go'));
193
+
194
+ expect(getAuthFlowState('drawer')?.active).toBe('register');
195
+ });
196
+
197
+ it('walks the nearest flow back through auth-back', () => {
198
+ setAuthStep('drawer', 'login');
199
+ setAuthStep('drawer', 'register');
200
+
201
+ render(
202
+ <AuthFlowIdContext.Provider value="drawer">
203
+ <Trigger properties={{ actionType: 'auth-back' }} />
204
+ </AuthFlowIdContext.Provider>
205
+ );
206
+
207
+ fireEvent.click(screen.getByText('go'));
208
+
209
+ expect(getAuthFlowState('drawer')?.active).toBe('login');
210
+ });
211
+ });
@@ -0,0 +1,290 @@
1
+ import {
2
+ __resetAuthFlowBusForTests,
3
+ DEFAULT_AUTH_FLOW_ID,
4
+ clearPendingOtp,
5
+ getAuthFlowState,
6
+ getPendingOtp,
7
+ goBackAuthStep,
8
+ registerAuthFlowMount,
9
+ resetAuthFlow,
10
+ resolveAuthStepTarget,
11
+ setAuthStep,
12
+ setPendingOtp,
13
+ subscribeToAuthFlows
14
+ } from '../auth/auth-flow-bus';
15
+
16
+ describe('auth flow bus', () => {
17
+ beforeEach(() => {
18
+ __resetAuthFlowBusForTests();
19
+ });
20
+
21
+ it('starts every flow unset, so the flow block falls back to its authored initial step', () => {
22
+ expect(getAuthFlowState('auth')).toBeNull();
23
+ });
24
+
25
+ it('records a step and stacks history in navigation order', () => {
26
+ setAuthStep('auth', 'login');
27
+ setAuthStep('auth', 'register');
28
+
29
+ expect(getAuthFlowState('auth')).toEqual({
30
+ active: 'register',
31
+ history: ['login']
32
+ });
33
+ });
34
+
35
+ it('ignores a re-selection of the active step instead of stacking a phantom back entry', () => {
36
+ setAuthStep('auth', 'login');
37
+ setAuthStep('auth', 'login');
38
+
39
+ expect(getAuthFlowState('auth')?.history).toEqual([]);
40
+ });
41
+
42
+ it('walks back through history one step at a time', () => {
43
+ setAuthStep('auth', 'login');
44
+ setAuthStep('auth', 'phone');
45
+ setAuthStep('auth', 'register');
46
+
47
+ goBackAuthStep('auth');
48
+ expect(getAuthFlowState('auth')?.active).toBe('phone');
49
+
50
+ goBackAuthStep('auth');
51
+ expect(getAuthFlowState('auth')?.active).toBe('login');
52
+ });
53
+
54
+ it('resets to unset when back runs out of history — the journey returns to its start', () => {
55
+ setAuthStep('auth', 'login');
56
+
57
+ goBackAuthStep('auth');
58
+
59
+ expect(getAuthFlowState('auth')).toBeNull();
60
+ });
61
+
62
+ it('keeps flows independent of each other', () => {
63
+ setAuthStep('auth', 'login');
64
+ setAuthStep('newsletter-gate', 'register');
65
+
66
+ resetAuthFlow('auth');
67
+
68
+ expect(getAuthFlowState('auth')).toBeNull();
69
+ expect(getAuthFlowState('newsletter-gate')?.active).toBe('register');
70
+ });
71
+
72
+ it('notifies subscribers on every change and stops after unsubscribe', () => {
73
+ const listener = jest.fn();
74
+ const unsubscribe = subscribeToAuthFlows(listener);
75
+
76
+ setAuthStep('auth', 'login');
77
+ expect(listener).toHaveBeenCalledTimes(1);
78
+
79
+ unsubscribe();
80
+ setAuthStep('auth', 'register');
81
+ expect(listener).toHaveBeenCalledTimes(1);
82
+ });
83
+
84
+ it('carries one pending OTP for the page and clears it explicitly', () => {
85
+ setPendingOtp({ kind: 'register', phone: '5300000000', payload: { a: 1 } });
86
+
87
+ expect(getPendingOtp()?.kind).toBe('register');
88
+
89
+ clearPendingOtp();
90
+ expect(getPendingOtp()).toBeNull();
91
+ });
92
+
93
+ describe('mount tracking', () => {
94
+ it('resets a fresh-start flow only when its LAST mount leaves', () => {
95
+ // A desktop drawer and a mobile sheet share the flow id; responsive
96
+ // hiding is CSS, so both are mounted at once. The first unmount must
97
+ // not reset the journey out from under the survivor.
98
+ const releaseDesktop = registerAuthFlowMount('auth', {
99
+ resetOnLastUnmount: true
100
+ });
101
+ const releaseMobile = registerAuthFlowMount('auth', {
102
+ resetOnLastUnmount: true
103
+ });
104
+
105
+ setAuthStep('auth', 'register');
106
+ setPendingOtp({ kind: 'login', phone: '5300000000', scope: 'auth' });
107
+
108
+ releaseDesktop();
109
+ expect(getAuthFlowState('auth')?.active).toBe('register');
110
+ expect(getPendingOtp()).not.toBeNull();
111
+
112
+ releaseMobile();
113
+ expect(getAuthFlowState('auth')).toBeNull();
114
+ // An abandoned verification holds a password in its payload; it must
115
+ // not resurface in a later, unrelated login.
116
+ expect(getPendingOtp()).toBeNull();
117
+ });
118
+
119
+ it('leaves a verification belonging to another journey alone', () => {
120
+ // The page carries two flows — a header drawer and an inline sign-up —
121
+ // and the drawer closing must not drop the sign-up's verification. The
122
+ // reset beside this clear has always been per flow; the clear was not,
123
+ // so ONE page-wide slot was emptied by whichever flow left first.
124
+ setPendingOtp({
125
+ kind: 'register',
126
+ phone: '5300000000',
127
+ scope: 'checkout'
128
+ });
129
+
130
+ const releaseDrawer = registerAuthFlowMount('auth', {
131
+ resetOnLastUnmount: true
132
+ });
133
+ releaseDrawer();
134
+
135
+ expect(getPendingOtp()?.scope).toBe('checkout');
136
+ });
137
+
138
+ it('leaves a verification that belongs to no flow alone', () => {
139
+ // A register form and an OTP form composed side by side, with no flow
140
+ // around them: nothing about a drawer opening and closing elsewhere on
141
+ // the page has anything to say about that journey.
142
+ setPendingOtp({ kind: 'register', phone: '5300000000' });
143
+
144
+ registerAuthFlowMount('auth', { resetOnLastUnmount: true })();
145
+
146
+ expect(getPendingOtp()).not.toBeNull();
147
+ });
148
+
149
+ it('releases a registration only once, so StrictMode double cleanup cannot over-count', () => {
150
+ const releaseFirst = registerAuthFlowMount('auth', {
151
+ resetOnLastUnmount: true
152
+ });
153
+ const releaseSecond = registerAuthFlowMount('auth', {
154
+ resetOnLastUnmount: true
155
+ });
156
+
157
+ setAuthStep('auth', 'login');
158
+
159
+ releaseFirst();
160
+ releaseFirst();
161
+
162
+ expect(getAuthFlowState('auth')?.active).toBe('login');
163
+
164
+ releaseSecond();
165
+ expect(getAuthFlowState('auth')).toBeNull();
166
+ });
167
+
168
+ it('keeps a flow that opted out of fresh starts', () => {
169
+ const release = registerAuthFlowMount('auth', {
170
+ resetOnLastUnmount: false
171
+ });
172
+
173
+ setAuthStep('auth', 'register');
174
+ release();
175
+
176
+ expect(getAuthFlowState('auth')?.active).toBe('register');
177
+ });
178
+ });
179
+
180
+ describe('resolveAuthStepTarget', () => {
181
+ it('reads a bare step key against the nearest flow', () => {
182
+ expect(resolveAuthStepTarget('register', 'drawer')).toEqual({
183
+ flowId: 'drawer',
184
+ stepKey: 'register'
185
+ });
186
+ });
187
+
188
+ it('falls back to the default flow when no flow is in scope', () => {
189
+ expect(resolveAuthStepTarget('register')).toEqual({
190
+ flowId: DEFAULT_AUTH_FLOW_ID,
191
+ stepKey: 'register'
192
+ });
193
+ });
194
+
195
+ it('lets flow:step name a flow outright — the cross-placeholder spelling', () => {
196
+ expect(resolveAuthStepTarget('drawer:register', 'ignored')).toEqual({
197
+ flowId: 'drawer',
198
+ stepKey: 'register'
199
+ });
200
+ });
201
+
202
+ it('treats an empty target — and an empty step — as no target at all', () => {
203
+ expect(resolveAuthStepTarget('')).toBeNull();
204
+ expect(resolveAuthStepTarget(' ')).toBeNull();
205
+ expect(resolveAuthStepTarget('drawer:')).toBeNull();
206
+ });
207
+ });
208
+ });
209
+
210
+ describe('a verification the shopper walked away from', () => {
211
+ beforeEach(() => {
212
+ __resetAuthFlowBusForTests();
213
+ });
214
+
215
+ const parked = () =>
216
+ setPendingOtp({
217
+ kind: 'register',
218
+ phone: '5551234567',
219
+ scope: 'auth',
220
+ stepKey: 'verify',
221
+ payload: { password: 'hunter2' }
222
+ });
223
+
224
+ it('is abandoned when the flow moves off the step holding it', () => {
225
+ setAuthStep('auth', 'verify');
226
+ parked();
227
+
228
+ setAuthStep('auth', 'login');
229
+
230
+ // Nothing on the page is waiting for it any more, and it carries a
231
+ // password: leaving it armed means the next OTP form to mount answers for
232
+ // a journey nobody came back to.
233
+ expect(getPendingOtp()).toBeNull();
234
+ });
235
+
236
+ it('is abandoned when the shopper walks back out of it', () => {
237
+ setAuthStep('auth', 'register');
238
+ setAuthStep('auth', 'verify');
239
+ parked();
240
+
241
+ goBackAuthStep('auth');
242
+
243
+ expect(getAuthFlowState('auth')?.active).toBe('register');
244
+ expect(getPendingOtp()).toBeNull();
245
+ });
246
+
247
+ it('survives a step change inside another flow', () => {
248
+ setAuthStep('auth', 'verify');
249
+ parked();
250
+
251
+ setAuthStep('checkout', 'login');
252
+
253
+ expect(getPendingOtp()).not.toBeNull();
254
+ });
255
+
256
+ it('survives arriving on the step it named', () => {
257
+ parked();
258
+
259
+ setAuthStep('auth', 'verify');
260
+
261
+ expect(getPendingOtp()).not.toBeNull();
262
+ });
263
+
264
+ it('is not tied to any step when the composition named none', () => {
265
+ // A register form and an OTP form side by side, no flow between them: the
266
+ // handoff is not a navigation, so navigating has nothing to say about it.
267
+ setPendingOtp({ kind: 'register', phone: '5551234567' });
268
+
269
+ setAuthStep('auth', 'login');
270
+
271
+ expect(getPendingOtp()).not.toBeNull();
272
+ });
273
+
274
+ it('does not outlive the visit on a page where nothing else clears it', () => {
275
+ jest.useFakeTimers();
276
+
277
+ try {
278
+ setPendingOtp({ kind: 'register', payload: { password: 'hunter2' } });
279
+ expect(getPendingOtp()).not.toBeNull();
280
+
281
+ // An SMS code expires long before this; the cap is here because the
282
+ // record holds a REGISTRATION, and module state lives as long as the tab.
283
+ jest.advanceTimersByTime(15 * 60 * 1000);
284
+
285
+ expect(getPendingOtp()).toBeNull();
286
+ } finally {
287
+ jest.useRealTimers();
288
+ }
289
+ });
290
+ });