@treely/strapi-slices 7.16.2 → 7.17.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.
@@ -0,0 +1,3 @@
1
+ /// <reference types="node" />
2
+ import type { ParsedUrlQuery } from 'querystring';
3
+ export declare function buildRedirectUrl(url: string, asPath: string, query: ParsedUrlQuery): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treely/strapi-slices",
3
- "version": "7.16.2",
3
+ "version": "7.17.0",
4
4
  "license": "MIT",
5
5
  "author": "Tree.ly FlexCo",
6
6
  "description": "@treely/strapi-slices is a open source library maintained by Tree.ly.",
@@ -0,0 +1,73 @@
1
+ import React from 'react';
2
+ import { render } from '../../test/testUtils';
3
+ import { mergeDeep } from '../../utils/mergeDeep';
4
+ import { useRouter, replaceSpy } from '../../../__mocks__/next/router';
5
+ import { DEFAULT_USE_ROUTER_RETURN_VALUE } from '../../test/defaultMocks/next';
6
+ import Redirect from '.';
7
+ import type { RedirectProps } from './Redirect';
8
+
9
+ const defaultProps: RedirectProps = {
10
+ slice: { url: 'https://redirect.com' },
11
+ };
12
+
13
+ const setup = (
14
+ props: Partial<RedirectProps> = {},
15
+ routerOverrides: any = {}
16
+ ) => {
17
+ const combinedProps = mergeDeep(defaultProps, props);
18
+ useRouter.mockReturnValue({
19
+ ...DEFAULT_USE_ROUTER_RETURN_VALUE,
20
+ asPath: '/from',
21
+ query: { utm_source: 'linkedin' },
22
+ replace: replaceSpy,
23
+ ...routerOverrides,
24
+ });
25
+ render(<Redirect {...combinedProps} />);
26
+ };
27
+
28
+ describe('Redirect component', () => {
29
+ const originalDateNow = Date.now;
30
+ const originalLocation = window.location;
31
+ const mockDateNow = jest.fn(() => 1730284800000);
32
+ const mockOrigin = 'https://origin.com';
33
+
34
+ beforeEach(() => {
35
+ Date.now = mockDateNow;
36
+ // @ts-ignore - Mocking window.location
37
+ Object.defineProperty(window, 'location', {
38
+ value: { ...originalLocation, origin: mockOrigin },
39
+ writable: true,
40
+ });
41
+ jest.clearAllMocks();
42
+ });
43
+
44
+ afterEach(() => {
45
+ Date.now = originalDateNow;
46
+ Object.defineProperty(window, 'location', {
47
+ value: originalLocation,
48
+ writable: true,
49
+ });
50
+ useRouter.mockRestore();
51
+ replaceSpy.mockRestore();
52
+ });
53
+
54
+ it('calls the redirect URL when rendering preserving the utm_* params and passing the source and ts params', () => {
55
+ setup();
56
+
57
+ expect(replaceSpy).toHaveBeenCalledTimes(1);
58
+ const calledUrl = replaceSpy.mock.calls[0][0];
59
+ const url = new URL(calledUrl);
60
+
61
+ // URLSearchParams.get automatically decodes values
62
+ expect(url.searchParams.get('source')).toBe('https://origin.com/from');
63
+ expect(url.searchParams.get('utm_source')).toBe('linkedin');
64
+ expect(url.searchParams.get('ts')).toBe('1730284800000');
65
+ expect(url.origin + url.pathname).toBe('https://redirect.com/');
66
+ });
67
+
68
+ it('does nothing when slice.url is empty', () => {
69
+ setup({ slice: { url: '' } });
70
+
71
+ expect(replaceSpy).not.toHaveBeenCalled();
72
+ });
73
+ });
@@ -1,5 +1,6 @@
1
1
  import { useRouter } from 'next/router';
2
2
  import React, { useEffect } from 'react';
3
+ import { buildRedirectUrl } from '../../utils/buildRedirectUrl';
3
4
 
4
5
  export interface RedirectProps {
5
6
  slice: {
@@ -9,10 +10,20 @@ export interface RedirectProps {
9
10
 
10
11
  export const Redirect = ({ slice }: RedirectProps): JSX.Element => {
11
12
  const router = useRouter();
13
+
12
14
  useEffect(() => {
13
- // When using `replace`, the current browser history entry will be replaced
14
- router.replace(slice.url);
15
- }, [slice.url]);
15
+ if (!slice.url) return;
16
+
17
+ // Build redirect URL
18
+ const redirectUrl = buildRedirectUrl(
19
+ slice.url,
20
+ router.asPath,
21
+ router.query
22
+ );
23
+
24
+ // Redirect
25
+ router.replace(redirectUrl);
26
+ }, [slice.url, router]);
16
27
 
17
28
  return <></>;
18
29
  };
@@ -0,0 +1,102 @@
1
+ import { buildRedirectUrl } from './buildRedirectUrl';
2
+
3
+ const ORIGIN = 'https://site.local';
4
+
5
+ const getParams = (u: string) => new URL(u).searchParams;
6
+
7
+ describe('buildRedirectUrl', () => {
8
+ let dateNowSpy: jest.SpyInstance<number, []>;
9
+
10
+ beforeAll(() => {
11
+ // stable origin for all tests
12
+ Object.defineProperty(window, 'location', {
13
+ value: { ...window.location, origin: ORIGIN },
14
+ writable: true,
15
+ });
16
+ });
17
+
18
+ beforeEach(() => {
19
+ // stable timestamp per test
20
+ dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1730284800000);
21
+ });
22
+
23
+ afterEach(() => {
24
+ dateNowSpy.mockRestore();
25
+ });
26
+
27
+ it('returns empty string for empty target url', () => {
28
+ expect(buildRedirectUrl('', '/from', {})).toBe('');
29
+ });
30
+
31
+ it('adds absolute source and ts, preserves existing target params', () => {
32
+ const out = buildRedirectUrl(
33
+ 'https://redirect.com/landing?existing=value',
34
+ '/from',
35
+ {}
36
+ );
37
+ const p = getParams(out);
38
+ expect(p.get('existing')).toBe('value');
39
+ expect(p.get('source')).toBe(`${ORIGIN}/from`);
40
+ expect(p.get('ts')).toBe('1730284800000');
41
+ });
42
+
43
+ it('forwards utm_* params from current page and ignores others', () => {
44
+ const out = buildRedirectUrl('https://redirect.com', '/page', {
45
+ utm_source: 'google',
46
+ utm_medium: 'cpc',
47
+ utm_campaign: 'spring',
48
+ other: 'ignore-me',
49
+ });
50
+ const p = getParams(out);
51
+ expect(p.get('utm_source')).toBe('google');
52
+ expect(p.get('utm_medium')).toBe('cpc');
53
+ expect(p.get('utm_campaign')).toBe('spring');
54
+ expect(p.get('other')).toBeNull();
55
+ });
56
+
57
+ it('includes query string in source when asPath has one', () => {
58
+ const out = buildRedirectUrl(
59
+ 'https://redirect.com',
60
+ '/from?utm_source=fb&x=1',
61
+ { utm_source: 'fb' }
62
+ );
63
+ const p = getParams(out);
64
+ expect(p.get('source')).toBe(`${ORIGIN}/from?utm_source=fb&x=1`);
65
+ });
66
+
67
+ it('handles relative target URLs using window.location.origin', () => {
68
+ const out = buildRedirectUrl('/relative', '/current', {});
69
+ expect(out.startsWith(`${ORIGIN}/relative?`)).toBe(true);
70
+ const p = getParams(out);
71
+ expect(p.get('source')).toBe(`${ORIGIN}/current`);
72
+ expect(p.get('ts')).toBe('1730284800000');
73
+ });
74
+
75
+ it('overwrites existing source in target (deduplicated)', () => {
76
+ const out = buildRedirectUrl('https://redirect.com?source=old', '/new', {});
77
+ const p = getParams(out);
78
+ expect(p.get('source')).toBe(`${ORIGIN}/new`);
79
+ expect(p.getAll('source').length).toBe(1);
80
+ });
81
+
82
+ it('skips array values from router.query', () => {
83
+ const out = buildRedirectUrl('https://redirect.com', '/x', {
84
+ utm_source: ['a', 'b'] as any,
85
+ utm_medium: ['m'] as any,
86
+ });
87
+ const p = getParams(out);
88
+ expect(p.get('utm_source')).toBeNull();
89
+ expect(p.get('utm_medium')).toBeNull();
90
+ });
91
+
92
+ it('does not add utm params when none present', () => {
93
+ const out = buildRedirectUrl('https://redirect.com', '/from', {
94
+ foo: 'bar',
95
+ });
96
+ const p = getParams(out);
97
+ expect(p.get('utm_source')).toBeNull();
98
+ expect(p.get('utm_medium')).toBeNull();
99
+ expect(p.get('utm_campaign')).toBeNull();
100
+ expect(p.get('source')).toBe(`${ORIGIN}/from`);
101
+ });
102
+ });
@@ -0,0 +1,34 @@
1
+ import type { ParsedUrlQuery } from 'querystring';
2
+
3
+ export function buildRedirectUrl(
4
+ url: string,
5
+ asPath: string,
6
+ query: ParsedUrlQuery
7
+ ): string {
8
+ if (!url) return '';
9
+
10
+ // Parse the base target URL
11
+ const target = new URL(url, window.location.origin);
12
+
13
+ // Merge existing params from the target
14
+ const mergedParams = new URLSearchParams(target.search);
15
+
16
+ // Add absolute source
17
+ const absoluteSource = `${window.location.origin}${asPath}`;
18
+ mergedParams.set('source', absoluteSource);
19
+
20
+ // Forward utm_* params from the current page
21
+ for (const [key, value] of Object.entries(query)) {
22
+ if (key.startsWith('utm_') && typeof value === 'string') {
23
+ mergedParams.set(key, value);
24
+ }
25
+ }
26
+
27
+ // Add timestamp
28
+ mergedParams.set('ts', Date.now().toString());
29
+
30
+ // Build final merged URL
31
+ target.search = mergedParams.toString();
32
+
33
+ return target.toString();
34
+ }
@@ -1,30 +0,0 @@
1
- import React from 'react';
2
- import { render } from '../../test/testUtils';
3
- import { mergeDeep } from '../../utils/mergeDeep';
4
- import { replaceSpy, useRouter } from '../../../__mocks__/next/router';
5
- import Redirect from '.';
6
- import { RedirectProps } from './Redirect';
7
-
8
- const defaultProps: RedirectProps = {
9
- slice: {
10
- url: 'https://redirect.com',
11
- },
12
- };
13
-
14
- const setup = (props = {}) => {
15
- const combinedProps = mergeDeep(defaultProps, props);
16
- render(<Redirect {...combinedProps} />);
17
- };
18
-
19
- describe('The Redirect component', () => {
20
- afterEach(() => {
21
- replaceSpy.mockRestore();
22
- useRouter.mockRestore();
23
- });
24
-
25
- it('calls the redirect URL when rendering', () => {
26
- setup();
27
-
28
- expect(replaceSpy).toHaveBeenCalledWith('https://redirect.com');
29
- });
30
- });