@akinon/next 1.126.12-v1-rc.8 → 1.126.12
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 +4 -3
- package/__tests__/get-callback-redirect-url.test.ts +73 -0
- package/__tests__/redirect.test.ts +77 -0
- package/__tests__/rtk-query-error-handler.test.ts +68 -0
- package/package.json +2 -2
- package/redux/middlewares/index.ts +5 -1
- package/utils/get-callback-redirect-url.ts +33 -0
- package/utils/redirect.ts +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# @akinon/next
|
|
2
2
|
|
|
3
|
-
## 1.126.12
|
|
3
|
+
## 1.126.12
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
|
|
7
|
+
- 0a64bb5: ZERO-5104: Keep the query of the page when a shopper is sent to the login page
|
|
8
|
+
|
|
9
|
+
A 401 signed the shopper out to the login page with only `window.location.pathname` as callbackUrl, and the server `redirect` helper embedded its callbackUrl unencoded, so UTM and other parameters were lost before analytics recorded the visit and it was reported as "not set / Unassigned". Both now build the URL with `getCallbackRedirectUrl`: the query is repeated on the login page and the whole page URL is kept, encoded, in callbackUrl.
|
|
9
10
|
|
|
10
11
|
## 1.126.11
|
|
11
12
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { getCallbackRedirectUrl } from '../utils/get-callback-redirect-url';
|
|
2
|
+
|
|
3
|
+
const AUTH = '/users/auth';
|
|
4
|
+
|
|
5
|
+
const paramsOf = (url: string) =>
|
|
6
|
+
new URL(url, 'https://example.com').searchParams;
|
|
7
|
+
|
|
8
|
+
describe('getCallbackRedirectUrl', () => {
|
|
9
|
+
it('repeats the query on the target and keeps the whole URL in callbackUrl', () => {
|
|
10
|
+
const callbackUrl = '/account/orders/?utm_source=edm&utm_medium=cpc';
|
|
11
|
+
const url = getCallbackRedirectUrl(AUTH, callbackUrl);
|
|
12
|
+
|
|
13
|
+
expect(url).toBe(
|
|
14
|
+
'/users/auth?utm_source=edm&utm_medium=cpc&callbackUrl=%2Faccount%2Forders%2F%3Futm_source%3Dedm%26utm_medium%3Dcpc'
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const params = paramsOf(url);
|
|
18
|
+
expect(params.get('utm_source')).toBe('edm');
|
|
19
|
+
expect(params.get('utm_medium')).toBe('cpc');
|
|
20
|
+
expect(params.get('callbackUrl')).toBe(callbackUrl);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('only adds callbackUrl when the page has no query', () => {
|
|
24
|
+
expect(getCallbackRedirectUrl(AUTH, '/account/orders/')).toBe(
|
|
25
|
+
'/users/auth?callbackUrl=%2Faccount%2Forders%2F'
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('forwards params with their original encoding and repeated keys', () => {
|
|
30
|
+
const callbackUrl =
|
|
31
|
+
'/list/?utm_campaign=yaz%20indirimi&color=red&color=blue&q=%C3%A7ay';
|
|
32
|
+
const url = getCallbackRedirectUrl(AUTH, callbackUrl);
|
|
33
|
+
|
|
34
|
+
expect(url).toBe(
|
|
35
|
+
`/users/auth?utm_campaign=yaz%20indirimi&color=red&color=blue&q=%C3%A7ay&callbackUrl=${encodeURIComponent(
|
|
36
|
+
callbackUrl
|
|
37
|
+
)}`
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const params = paramsOf(url);
|
|
41
|
+
expect(params.get('utm_campaign')).toBe('yaz indirimi');
|
|
42
|
+
expect(params.getAll('color')).toEqual(['red', 'blue']);
|
|
43
|
+
expect(params.get('q')).toBe('çay');
|
|
44
|
+
expect(params.get('callbackUrl')).toBe(callbackUrl);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does not forward a callbackUrl the page already carries', () => {
|
|
48
|
+
const callbackUrl = '/users/auth?callbackUrl=%2Faccount%2F&utm_source=edm';
|
|
49
|
+
const params = paramsOf(getCallbackRedirectUrl(AUTH, callbackUrl));
|
|
50
|
+
|
|
51
|
+
expect(params.getAll('callbackUrl')).toEqual([callbackUrl]);
|
|
52
|
+
expect(params.get('utm_source')).toBe('edm');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('appends to a target that already has a query', () => {
|
|
56
|
+
expect(
|
|
57
|
+
getCallbackRedirectUrl(
|
|
58
|
+
'/users/auth?tab=login',
|
|
59
|
+
'/account/?utm_source=edm'
|
|
60
|
+
)
|
|
61
|
+
).toBe(
|
|
62
|
+
'/users/auth?tab=login&utm_source=edm&callbackUrl=%2Faccount%2F%3Futm_source%3Dedm'
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('keeps a hash out of the forwarded params', () => {
|
|
67
|
+
expect(
|
|
68
|
+
getCallbackRedirectUrl(AUTH, '/account/?utm_source=edm#orders')
|
|
69
|
+
).toBe(
|
|
70
|
+
'/users/auth?utm_source=edm&callbackUrl=%2Faccount%2F%3Futm_source%3Dedm%23orders'
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const mockNextRedirect = jest.fn();
|
|
2
|
+
const mockHeaders = new Map<string, string>();
|
|
3
|
+
const mockServerVariables = { locale: 'en' };
|
|
4
|
+
|
|
5
|
+
jest.mock('next/navigation', () => ({
|
|
6
|
+
redirect: (...args: unknown[]) => mockNextRedirect(...args)
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.mock('next/headers', () => ({
|
|
10
|
+
headers: () => mockHeaders
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
jest.mock('@akinon/next/utils/server-variables', () => ({
|
|
14
|
+
ServerVariables: mockServerVariables
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
jest.mock('settings', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
default: {
|
|
20
|
+
localization: {
|
|
21
|
+
locales: [{ value: 'en' }, { value: 'tr' }],
|
|
22
|
+
defaultLocaleValue: 'en',
|
|
23
|
+
localeUrlStrategy: 'hide-default-locale'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { redirect } from '../utils/redirect';
|
|
29
|
+
|
|
30
|
+
describe('redirect', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
mockNextRedirect.mockClear();
|
|
33
|
+
mockServerVariables.locale = 'en';
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('keeps the query of the page on the target and in callbackUrl', async () => {
|
|
37
|
+
mockHeaders.set(
|
|
38
|
+
'pz-url',
|
|
39
|
+
'https://example.com/account/orders/?utm_source=edm&utm_medium=cpc&foo=bar%20baz'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
await redirect('/users/auth');
|
|
43
|
+
|
|
44
|
+
expect(mockNextRedirect).toHaveBeenCalledWith(
|
|
45
|
+
'/users/auth?utm_source=edm&utm_medium=cpc&foo=bar%20baz&callbackUrl=%2Faccount%2Forders%2F%3Futm_source%3Dedm%26utm_medium%3Dcpc%26foo%3Dbar%2520baz'
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const [redirectUrl] = mockNextRedirect.mock.calls[0];
|
|
49
|
+
expect(
|
|
50
|
+
new URL(redirectUrl, 'https://example.com').searchParams.get(
|
|
51
|
+
'callbackUrl'
|
|
52
|
+
)
|
|
53
|
+
).toBe('/account/orders/?utm_source=edm&utm_medium=cpc&foo=bar%20baz');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('prefixes the locale on the target and strips it from callbackUrl', async () => {
|
|
57
|
+
mockServerVariables.locale = 'tr';
|
|
58
|
+
mockHeaders.set('pz-url', 'https://example.com/tr/account/?utm_source=edm');
|
|
59
|
+
|
|
60
|
+
await redirect('/users/auth', 'replace' as never);
|
|
61
|
+
|
|
62
|
+
expect(mockNextRedirect).toHaveBeenCalledWith(
|
|
63
|
+
'/tr/users/auth?utm_source=edm&callbackUrl=%2Faccount%2F%3Futm_source%3Dedm',
|
|
64
|
+
'replace'
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('only adds callbackUrl when the page has no query', async () => {
|
|
69
|
+
mockHeaders.set('pz-url', 'https://example.com/account/orders/');
|
|
70
|
+
|
|
71
|
+
await redirect('/users/auth');
|
|
72
|
+
|
|
73
|
+
expect(mockNextRedirect).toHaveBeenCalledWith(
|
|
74
|
+
'/users/auth?callbackUrl=%2Faccount%2Forders%2F'
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const mockSignOut = jest.fn(() => Promise.resolve());
|
|
2
|
+
|
|
3
|
+
jest.mock('next-auth/react', () => ({
|
|
4
|
+
signOut: (...args: unknown[]) => mockSignOut(...(args as []))
|
|
5
|
+
}));
|
|
6
|
+
jest.mock('routes', () => ({ ROUTES: { AUTH: '/users/auth' } }), {
|
|
7
|
+
virtual: true
|
|
8
|
+
});
|
|
9
|
+
jest.mock('../redux/middlewares/checkout', () => ({}));
|
|
10
|
+
jest.mock('../redux/middlewares/pre-order', () => ({
|
|
11
|
+
preOrderMiddlewares: []
|
|
12
|
+
}));
|
|
13
|
+
jest.mock('../data/client/api', () => ({ api: {} }));
|
|
14
|
+
jest.mock('@akinon/next/utils/log', () => ({
|
|
15
|
+
__esModule: true,
|
|
16
|
+
default: { debug: jest.fn(), warn: jest.fn() }
|
|
17
|
+
}));
|
|
18
|
+
jest.mock('@akinon/pz-saved-card', () => ({}), { virtual: true });
|
|
19
|
+
jest.mock('@akinon/pz-cybersource-uc/src/redux/middleware', () => ({}), {
|
|
20
|
+
virtual: true
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
import { rtkQueryErrorHandler } from '../redux/middlewares';
|
|
24
|
+
|
|
25
|
+
const setLocation = (pathname: string, search: string) => {
|
|
26
|
+
(globalThis as unknown as { window: unknown }).window = {
|
|
27
|
+
location: { pathname, search }
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const dispatch401 = async () => {
|
|
32
|
+
const next = jest.fn();
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
await rtkQueryErrorHandler({} as any)(next)({
|
|
35
|
+
type: 'api/executeQuery/rejected',
|
|
36
|
+
payload: { status: 401 }
|
|
37
|
+
});
|
|
38
|
+
return next;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('rtkQueryErrorHandler', () => {
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
mockSignOut.mockClear();
|
|
44
|
+
delete (globalThis as unknown as { window?: unknown }).window;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('signs out to the login page keeping the query of the current page', async () => {
|
|
48
|
+
setLocation('/account/orders/', '?utm_source=edm&utm_medium=cpc');
|
|
49
|
+
|
|
50
|
+
const next = await dispatch401();
|
|
51
|
+
|
|
52
|
+
expect(mockSignOut).toHaveBeenCalledWith({
|
|
53
|
+
callbackUrl:
|
|
54
|
+
'/users/auth?utm_source=edm&utm_medium=cpc&callbackUrl=%2Faccount%2Forders%2F%3Futm_source%3Dedm%26utm_medium%3Dcpc'
|
|
55
|
+
});
|
|
56
|
+
expect(next).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('keeps the locale prefix of the current page in callbackUrl', async () => {
|
|
60
|
+
setLocation('/tr/account/', '');
|
|
61
|
+
|
|
62
|
+
await dispatch401();
|
|
63
|
+
|
|
64
|
+
expect(mockSignOut).toHaveBeenCalledWith({
|
|
65
|
+
callbackUrl: '/users/auth?callbackUrl=%2Ftr%2Faccount%2F'
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/next",
|
|
3
3
|
"description": "Core package for Project Zero Next",
|
|
4
|
-
"version": "1.126.12
|
|
4
|
+
"version": "1.126.12",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"set-cookie-parser": "2.6.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@akinon/eslint-plugin-projectzero": "1.126.12
|
|
38
|
+
"@akinon/eslint-plugin-projectzero": "1.126.12",
|
|
39
39
|
"@babel/core": "7.26.10",
|
|
40
40
|
"@babel/preset-env": "7.26.9",
|
|
41
41
|
"@babel/preset-typescript": "7.27.0",
|
|
@@ -15,6 +15,7 @@ import { preOrderMiddlewares } from './pre-order';
|
|
|
15
15
|
|
|
16
16
|
import { api } from '../../data/client/api';
|
|
17
17
|
import logger from '@akinon/next/utils/log';
|
|
18
|
+
import { getCallbackRedirectUrl } from '@akinon/next/utils/get-callback-redirect-url';
|
|
18
19
|
|
|
19
20
|
// Plugin middlewares
|
|
20
21
|
import { savedCardMiddleware } from '@akinon/pz-saved-card';
|
|
@@ -28,7 +29,10 @@ export const rtkQueryErrorHandler: Middleware =
|
|
|
28
29
|
() => (next) => async (action) => {
|
|
29
30
|
if (action?.payload?.status === 401) {
|
|
30
31
|
await signOut({
|
|
31
|
-
callbackUrl:
|
|
32
|
+
callbackUrl: getCallbackRedirectUrl(
|
|
33
|
+
ROUTES.AUTH,
|
|
34
|
+
window.location.pathname + window.location.search
|
|
35
|
+
)
|
|
32
36
|
});
|
|
33
37
|
logger.debug('Session expired: 401');
|
|
34
38
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the URL that sends the visitor to `path` (e.g. the login page) and
|
|
3
|
+
* brings them back to `callbackUrl` afterwards.
|
|
4
|
+
*
|
|
5
|
+
* The query of `callbackUrl` (utm_*, gclid, ...) is repeated on `path` as is:
|
|
6
|
+
* these redirects can run before analytics records the page being left, so
|
|
7
|
+
* the page they land on is the one that has to carry the traffic source.
|
|
8
|
+
*
|
|
9
|
+
* `callbackUrl` itself is encoded as a whole. Left raw, its `&` would split it
|
|
10
|
+
* into parameters of `path` and `searchParams.get('callbackUrl')` would only
|
|
11
|
+
* return the part before the first one.
|
|
12
|
+
*/
|
|
13
|
+
export const getCallbackRedirectUrl = (
|
|
14
|
+
path: string,
|
|
15
|
+
callbackUrl: string
|
|
16
|
+
): string => {
|
|
17
|
+
const [pathAndQuery] = callbackUrl.split('#');
|
|
18
|
+
const queryIndex = pathAndQuery.indexOf('?');
|
|
19
|
+
const forwardedParams =
|
|
20
|
+
queryIndex === -1
|
|
21
|
+
? []
|
|
22
|
+
: pathAndQuery
|
|
23
|
+
.slice(queryIndex + 1)
|
|
24
|
+
.split('&')
|
|
25
|
+
.filter((param) => param && param.split('=')[0] !== 'callbackUrl');
|
|
26
|
+
|
|
27
|
+
const params = [
|
|
28
|
+
...forwardedParams,
|
|
29
|
+
`callbackUrl=${encodeURIComponent(callbackUrl)}`
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
return `${path}${path.includes('?') ? '&' : '?'}${params.join('&')}`;
|
|
33
|
+
};
|
package/utils/redirect.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { headers } from 'next/headers';
|
|
|
4
4
|
import { ServerVariables } from '@akinon/next/utils/server-variables';
|
|
5
5
|
import { getUrlPathWithLocale } from '@akinon/next/utils/localization';
|
|
6
6
|
import { urlLocaleMatcherRegex } from '@akinon/next/utils';
|
|
7
|
+
import { getCallbackRedirectUrl } from '@akinon/next/utils/get-callback-redirect-url';
|
|
7
8
|
|
|
8
9
|
export const redirect = (path: string, type?: RedirectType) => {
|
|
9
10
|
const nextHeaders = headers();
|
|
@@ -15,18 +16,18 @@ export const redirect = (path: string, type?: RedirectType) => {
|
|
|
15
16
|
(locale) => locale.value === ServerVariables.locale
|
|
16
17
|
);
|
|
17
18
|
|
|
18
|
-
const searchParams = new URLSearchParams(pageUrl.search);
|
|
19
|
-
|
|
20
19
|
const callbackUrl =
|
|
21
|
-
pageUrl.pathname.replace(urlLocaleMatcherRegex, '') +
|
|
22
|
-
(searchParams.toString() ? `?${searchParams.toString()}` : '');
|
|
20
|
+
pageUrl.pathname.replace(urlLocaleMatcherRegex, '') + pageUrl.search;
|
|
23
21
|
|
|
24
22
|
const redirectUrlWithLocale = getUrlPathWithLocale(
|
|
25
23
|
path,
|
|
26
24
|
currentLocale?.value
|
|
27
25
|
);
|
|
28
26
|
|
|
29
|
-
const redirectUrl =
|
|
27
|
+
const redirectUrl = getCallbackRedirectUrl(
|
|
28
|
+
redirectUrlWithLocale,
|
|
29
|
+
callbackUrl
|
|
30
|
+
);
|
|
30
31
|
|
|
31
32
|
if (type) {
|
|
32
33
|
return nextRedirect(redirectUrl, type);
|