@akinon/pz-theme 2.0.87-rc.0 → 2.0.88-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 CHANGED
@@ -1,38 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.87-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.87-rc.0
35
- - @akinon/theme-contract@2.0.87-rc.0
3
+ ## 2.0.88-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.88-beta.0
9
+ - @akinon/theme-contract@2.0.88-beta.0
10
+
11
+ ## 2.0.87
12
+
13
+ ### Patch Changes
14
+
15
+ - 2fd90e5d: ZERO-4902: Implement auth commerce event tracking for login and registration flows
16
+ - @akinon/next@2.0.87
17
+ - @akinon/theme-contract@2.0.87
36
18
 
37
19
  ## 2.0.86
38
20
 
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-rc.0",
4
+ "version": "2.0.88-beta.0",
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-rc.0",
11
+ "@akinon/next": "2.0.88-beta.0",
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-rc.0",
16
+ "@akinon/theme-contract": "2.0.88-beta.0",
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-rc.0",
23
+ "@akinon/next": "2.0.88-beta.0",
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
- setError(readAuthError(signInErrorDetail(response)) || errorLabel);
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, on success only: a failed submit is an error state,
338
- // not a login. `method` mirrors what the credentials provider is.
339
- emitCommerceEvent(mode === 'register' ? 'sign_up' : 'login', {
340
- method: providerId
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();
@@ -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. */