@akinon/pz-theme 2.0.87-rc.0 → 2.0.87
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
CHANGED
|
@@ -1,38 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.87
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
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.87-rc.0
|
|
35
|
-
- @akinon/theme-contract@2.0.87-rc.0
|
|
3
|
+
## 2.0.87
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2fd90e5d: ZERO-4902: Implement auth commerce event tracking for login and registration flows
|
|
8
|
+
- @akinon/next@2.0.87
|
|
9
|
+
- @akinon/theme-contract@2.0.87
|
|
36
10
|
|
|
37
11
|
## 2.0.86
|
|
38
12
|
|
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.87
|
|
4
|
+
"version": "2.0.87",
|
|
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.87
|
|
11
|
+
"@akinon/next": "2.0.87",
|
|
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.87
|
|
16
|
+
"@akinon/theme-contract": "2.0.87",
|
|
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.87
|
|
23
|
+
"@akinon/next": "2.0.87",
|
|
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,206 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import AuthFormBlock from '../blocks/auth-form-block';
|
|
5
|
+
import AccountMenuBlock from '../blocks/account-menu-block';
|
|
6
|
+
import { COMMERCE_EVENT_NAME, CommerceEventDetail } from '../utils/analytics';
|
|
7
|
+
import { Block } from '../theme-block';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The auth moments a brand measures its membership funnel with (ZERO-4902):
|
|
11
|
+
* a successful login, a registration that succeeded or was REJECTED, and the
|
|
12
|
+
* logout click. The blocks report them as neutral `pz:commerce` signals — the
|
|
13
|
+
* sign_up one carrying its outcome on `status` — and stay silent for the
|
|
14
|
+
* moments the storefronts do not report: a failed login, and the captcha/OTP
|
|
15
|
+
* hand-off, which is a pending step rather than a verdict.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const signIn = jest.fn();
|
|
19
|
+
const signOut = jest.fn();
|
|
20
|
+
const logoutUnwrap = jest.fn(() => Promise.resolve({}));
|
|
21
|
+
const logoutMutation = jest.fn(() => ({ unwrap: logoutUnwrap }));
|
|
22
|
+
|
|
23
|
+
jest.mock('next-auth/react', () => ({
|
|
24
|
+
signIn: (...args: unknown[]) => signIn(...args),
|
|
25
|
+
signOut: (...args: unknown[]) => signOut(...args),
|
|
26
|
+
useSession: () => ({ data: null, status: 'unauthenticated' })
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
jest.mock('next/navigation', () => ({
|
|
30
|
+
useRouter: () => ({
|
|
31
|
+
push: jest.fn(),
|
|
32
|
+
refresh: jest.fn(),
|
|
33
|
+
replace: jest.fn()
|
|
34
|
+
}),
|
|
35
|
+
usePathname: () => '/account/orders'
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
jest.mock('@akinon/next/data/client/user', () => ({
|
|
39
|
+
useLogoutMutation: () => [logoutMutation, { isLoading: false }]
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const authField = (id: string, fieldType: string, order: number): Block => ({
|
|
43
|
+
id,
|
|
44
|
+
type: 'auth-field',
|
|
45
|
+
label: id,
|
|
46
|
+
properties: { fieldType },
|
|
47
|
+
styles: {},
|
|
48
|
+
order,
|
|
49
|
+
hidden: false
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const formBlock = (properties: Record<string, unknown>): Block => ({
|
|
53
|
+
id: 'auth-form-1',
|
|
54
|
+
type: 'auth-form',
|
|
55
|
+
label: 'Auth Form',
|
|
56
|
+
properties,
|
|
57
|
+
styles: {},
|
|
58
|
+
order: 0,
|
|
59
|
+
hidden: false,
|
|
60
|
+
blocks: [
|
|
61
|
+
authField('field-email', 'email', 0),
|
|
62
|
+
authField('field-password', 'password', 1)
|
|
63
|
+
]
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const renderForm = (properties: Record<string, unknown> = {}) =>
|
|
67
|
+
render(
|
|
68
|
+
<AuthFormBlock
|
|
69
|
+
block={formBlock(properties)}
|
|
70
|
+
placeholderId="placeholder-1"
|
|
71
|
+
sectionId="section-1"
|
|
72
|
+
currentBreakpoint="desktop"
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const fillAndSubmit = () => {
|
|
77
|
+
fireEvent.change(document.querySelector('input[name="email"]') as Element, {
|
|
78
|
+
target: { value: 'shopper@example.com' }
|
|
79
|
+
});
|
|
80
|
+
fireEvent.change(
|
|
81
|
+
document.querySelector('input[name="password"]') as Element,
|
|
82
|
+
{ target: { value: 'Secret123!' } }
|
|
83
|
+
);
|
|
84
|
+
fireEvent.submit(document.querySelector('form') as HTMLFormElement);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const capturedEvents: CommerceEventDetail[] = [];
|
|
88
|
+
const captureCommerce = (event: Event) => {
|
|
89
|
+
capturedEvents.push((event as CustomEvent<CommerceEventDetail>).detail);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// next-auth v5 keeps `error` at the enum name and serialises the AuthError[]
|
|
93
|
+
// onto `code` — the same shape the brands' own views parse.
|
|
94
|
+
const rejectedSignIn = (errors: Array<Record<string, unknown>>) => ({
|
|
95
|
+
error: 'CredentialsSignin',
|
|
96
|
+
code: JSON.stringify(errors)
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('auth commerce signals', () => {
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
capturedEvents.length = 0;
|
|
102
|
+
signIn.mockReset();
|
|
103
|
+
signOut.mockClear();
|
|
104
|
+
logoutMutation.mockClear();
|
|
105
|
+
logoutUnwrap.mockClear();
|
|
106
|
+
window.addEventListener(COMMERCE_EVENT_NAME, captureCommerce);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
window.removeEventListener(COMMERCE_EVENT_NAME, captureCommerce);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('reports a successful registration as sign_up with the success outcome', async () => {
|
|
114
|
+
signIn.mockResolvedValue({ error: null, url: '/' });
|
|
115
|
+
|
|
116
|
+
renderForm({ mode: 'register' });
|
|
117
|
+
fillAndSubmit();
|
|
118
|
+
|
|
119
|
+
await waitFor(() => expect(capturedEvents).toHaveLength(1));
|
|
120
|
+
expect(capturedEvents[0]).toMatchObject({
|
|
121
|
+
action: 'sign_up',
|
|
122
|
+
status: 'success'
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('reports a REJECTED registration as sign_up with the error outcome', async () => {
|
|
127
|
+
signIn.mockResolvedValue(
|
|
128
|
+
rejectedSignIn([
|
|
129
|
+
{ type: 'field_errors', data: [{ name: 'email', value: 'Taken.' }] }
|
|
130
|
+
])
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
renderForm({ mode: 'register' });
|
|
134
|
+
fillAndSubmit();
|
|
135
|
+
|
|
136
|
+
await waitFor(() => expect(capturedEvents).toHaveLength(1));
|
|
137
|
+
expect(capturedEvents[0]).toMatchObject({
|
|
138
|
+
action: 'sign_up',
|
|
139
|
+
status: 'error'
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('stays silent when the registration hands off to OTP or captcha', async () => {
|
|
144
|
+
signIn.mockResolvedValueOnce(rejectedSignIn([{ type: 'otp' }]));
|
|
145
|
+
|
|
146
|
+
renderForm({ mode: 'register' });
|
|
147
|
+
fillAndSubmit();
|
|
148
|
+
await waitFor(() => expect(signIn).toHaveBeenCalledTimes(1));
|
|
149
|
+
|
|
150
|
+
signIn.mockResolvedValueOnce(rejectedSignIn([{ type: 'captcha' }]));
|
|
151
|
+
fillAndSubmit();
|
|
152
|
+
await waitFor(() => expect(signIn).toHaveBeenCalledTimes(2));
|
|
153
|
+
|
|
154
|
+
expect(capturedEvents).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('reports a successful login and stays silent on a failed one', async () => {
|
|
158
|
+
signIn.mockResolvedValueOnce(
|
|
159
|
+
rejectedSignIn([{ type: 'non_field_errors', data: ['Wrong password.'] }])
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
renderForm({ mode: 'login' });
|
|
163
|
+
fillAndSubmit();
|
|
164
|
+
await waitFor(() => expect(signIn).toHaveBeenCalledTimes(1));
|
|
165
|
+
expect(capturedEvents).toHaveLength(0);
|
|
166
|
+
|
|
167
|
+
signIn.mockResolvedValueOnce({ error: null, url: '/' });
|
|
168
|
+
fillAndSubmit();
|
|
169
|
+
|
|
170
|
+
await waitFor(() => expect(capturedEvents).toHaveLength(1));
|
|
171
|
+
expect(capturedEvents[0]).toMatchObject({ action: 'login' });
|
|
172
|
+
expect(capturedEvents[0].status).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('reports logout BEFORE the sessions are dropped', async () => {
|
|
176
|
+
let logoutSignalledFirst = false;
|
|
177
|
+
logoutMutation.mockImplementationOnce(() => {
|
|
178
|
+
logoutSignalledFirst = capturedEvents.some(
|
|
179
|
+
(detail) => detail.action === 'logout'
|
|
180
|
+
);
|
|
181
|
+
return { unwrap: logoutUnwrap };
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
render(
|
|
185
|
+
<AccountMenuBlock
|
|
186
|
+
block={{
|
|
187
|
+
id: 'account-menu-1',
|
|
188
|
+
type: 'account-menu',
|
|
189
|
+
label: 'Account Menu',
|
|
190
|
+
properties: {},
|
|
191
|
+
styles: {},
|
|
192
|
+
order: 0,
|
|
193
|
+
hidden: false
|
|
194
|
+
}}
|
|
195
|
+
placeholderId="placeholder-1"
|
|
196
|
+
sectionId="section-1"
|
|
197
|
+
currentBreakpoint="desktop"
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
fireEvent.click(screen.getByText('Log out'));
|
|
202
|
+
|
|
203
|
+
await waitFor(() => expect(signOut).toHaveBeenCalled());
|
|
204
|
+
expect(logoutSignalledFirst).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -5,6 +5,7 @@ import NextLink from 'next/link';
|
|
|
5
5
|
import { usePathname } from 'next/navigation';
|
|
6
6
|
import { signOut } from 'next-auth/react';
|
|
7
7
|
import { useLogoutMutation } from '@akinon/next/data/client/user';
|
|
8
|
+
import { emitCommerceEvent } from '../utils/analytics';
|
|
8
9
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
9
10
|
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
10
11
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
@@ -127,6 +128,11 @@ const AccountMenuBlock = ({
|
|
|
127
128
|
const handleLogout = async () => {
|
|
128
129
|
if (isDesigner) return;
|
|
129
130
|
|
|
131
|
+
// The logout moment is reported BEFORE either session drops — afterwards
|
|
132
|
+
// there is no member left to name. The brand bridge reads the member id
|
|
133
|
+
// while it still exists and hands the event over the signOut navigation.
|
|
134
|
+
emitCommerceEvent('logout', {});
|
|
135
|
+
|
|
130
136
|
// The commerce session must be dropped before the next-auth session,
|
|
131
137
|
// otherwise next-auth can immediately re-authenticate from osessionid.
|
|
132
138
|
try {
|
|
@@ -99,6 +99,32 @@ const signInErrorDetail = (
|
|
|
99
99
|
| undefined
|
|
100
100
|
): string | null | undefined => response?.code || response?.error;
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* True when the rejected submit is really a hand-off to another step — a
|
|
104
|
+
* captcha challenge or an OTP/SMS verification — rather than a validation
|
|
105
|
+
* verdict. A pending flow has no outcome yet, so it must not be reported as a
|
|
106
|
+
* failed registration (the live storefronts report nothing at that moment
|
|
107
|
+
* either).
|
|
108
|
+
*/
|
|
109
|
+
const AUTH_CHALLENGE_TYPES = new Set(['otp', 'captcha']);
|
|
110
|
+
|
|
111
|
+
const isPendingAuthChallenge = (error?: string | null): boolean => {
|
|
112
|
+
if (!error) return false;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(error);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
Array.isArray(parsed) &&
|
|
119
|
+
parsed.some((entry) =>
|
|
120
|
+
AUTH_CHALLENGE_TYPES.has(String((entry as { type?: unknown })?.type))
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
102
128
|
/**
|
|
103
129
|
* Functional auth block: login or register form driven by props.mode.
|
|
104
130
|
*
|
|
@@ -329,16 +355,32 @@ const AuthFormBlock = ({
|
|
|
329
355
|
});
|
|
330
356
|
|
|
331
357
|
if (!response || response.error) {
|
|
332
|
-
|
|
358
|
+
const errorDetail = signInErrorDetail(response);
|
|
359
|
+
|
|
360
|
+
// A rejected registration is itself a measurable outcome — the live
|
|
361
|
+
// storefronts report sign_up with an error type on it. A rejected
|
|
362
|
+
// LOGIN is not (they stay silent), and neither is a captcha/OTP
|
|
363
|
+
// hand-off, which is a pending step rather than a verdict.
|
|
364
|
+
if (mode === 'register' && !isPendingAuthChallenge(errorDetail)) {
|
|
365
|
+
emitCommerceEvent('sign_up', {
|
|
366
|
+
method: providerId,
|
|
367
|
+
status: 'error'
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
setError(readAuthError(errorDetail) || errorLabel);
|
|
333
372
|
|
|
334
373
|
return;
|
|
335
374
|
}
|
|
336
375
|
|
|
337
|
-
// GA4 auth events
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
376
|
+
// GA4 auth events: sign_up carries its outcome so a brand bridge can
|
|
377
|
+
// tell the successful registration from the rejected one above.
|
|
378
|
+
// `method` mirrors what the credentials provider is.
|
|
379
|
+
if (mode === 'register') {
|
|
380
|
+
emitCommerceEvent('sign_up', { method: providerId, status: 'success' });
|
|
381
|
+
} else {
|
|
382
|
+
emitCommerceEvent('login', { method: providerId });
|
|
383
|
+
}
|
|
342
384
|
|
|
343
385
|
if (closeModalsOnSuccess) closeAllModals();
|
|
344
386
|
router.refresh();
|
package/src/utils/analytics.ts
CHANGED
|
@@ -54,6 +54,13 @@ export interface CommerceFacts {
|
|
|
54
54
|
searchTerm?: string;
|
|
55
55
|
method?: string;
|
|
56
56
|
transactionId?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Outcome of an auth moment: `sign_up` carries 'success' or 'error' (a
|
|
59
|
+
* rejected registration is itself a measurable event). Deliberately NOT
|
|
60
|
+
* forwarded by the default GA4 bridge — how an outcome is reported is a
|
|
61
|
+
* brand schema decision.
|
|
62
|
+
*/
|
|
63
|
+
status?: 'success' | 'error';
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
/** What rides on the event: the named facts, plus whatever else a brand adds. */
|