Skip to content

Commit 861957b

Browse files
committed
Display privacy link in third party opt in
Introduce usePrivacyLink hook that returns url, label and link props for the privacy page. Consolidates URL manipulation from OptIn and ConsentBar into a single SSR-compatible helper that handles absolute, protocol- relative and relative URLs. REDMINE-21240
1 parent 892f7ca commit 861957b

10 files changed

Lines changed: 312 additions & 52 deletions

File tree

entry_types/scrolled/package/spec/frontend/features/thirdPartyConsent-spec.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,42 @@ describe('Third party consent', () => {
338338

339339
expect(container.querySelector('[data-file-name=SvgMedia]')).not.toBeNull();
340340
});
341+
342+
it('renders privacy link pointing to privacy page with vendor param', async () => {
343+
const {getByTestId} = await renderEntry({
344+
seed: {
345+
themeOptions: {thirdPartyConsent: {cookieName: 'optIn'}},
346+
contentElements: [{typeName: 'test'}],
347+
legalInfo: {
348+
imprint: {label: '', url: ''},
349+
copyright: {label: '', url: ''},
350+
privacy: {label: 'Privacy', url: 'https://example.com/privacy'}
351+
}
352+
}
353+
});
354+
355+
const {getByText} = within(getByTestId('test-content-element'));
356+
const link = getByText('Privacy');
357+
358+
expect(link).toHaveAttribute(
359+
'href',
360+
'https://example.com/privacy?vendors=someService#consent'
361+
);
362+
expect(link).toHaveAttribute('target', '_blank');
363+
});
364+
365+
it('does not render privacy link when no privacy url is configured', async () => {
366+
const {getByTestId} = await renderEntry({
367+
seed: {
368+
themeOptions: {thirdPartyConsent: {cookieName: 'optIn'}},
369+
contentElements: [{typeName: 'test'}]
370+
}
371+
});
372+
373+
const {queryByText} = within(getByTestId('test-content-element'));
374+
375+
expect(queryByText('Privacy')).toBeNull();
376+
});
341377
});
342378

343379
describe('opt in with implicit provider name', () => {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import {usePrivacyLink} from 'frontend/usePrivacyLink';
2+
3+
import {renderHookInEntry} from 'support';
4+
5+
describe('usePrivacyLink', () => {
6+
it('returns url and label without modifications when called without args', () => {
7+
const {result} = renderHookInEntry(
8+
() => usePrivacyLink(), {
9+
seed: {
10+
legalInfo: {
11+
privacy: {url: 'https://example.com/privacy', label: 'Privacy'}
12+
}
13+
}
14+
}
15+
);
16+
17+
expect(result.current.url).toEqual('https://example.com/privacy');
18+
expect(result.current.label).toEqual('Privacy');
19+
});
20+
21+
it('returns empty string when privacy url is empty', () => {
22+
const {result} = renderHookInEntry(
23+
() => usePrivacyLink({vendors: 'spotify'}), {
24+
seed: {
25+
legalInfo: {
26+
privacy: {url: ''}
27+
}
28+
}
29+
}
30+
);
31+
32+
expect(result.current.url).toEqual('');
33+
});
34+
35+
it('appends vendors param and consent hash to absolute url', () => {
36+
const {result} = renderHookInEntry(
37+
() => usePrivacyLink({vendors: 'spotify'}), {
38+
seed: {
39+
legalInfo: {
40+
privacy: {url: 'https://example.com/privacy'}
41+
}
42+
}
43+
}
44+
);
45+
46+
expect(result.current.url)
47+
.toEqual('https://example.com/privacy?vendors=spotify#consent');
48+
});
49+
50+
it('appends vendors param to url with existing params', () => {
51+
const {result} = renderHookInEntry(
52+
() => usePrivacyLink({vendors: 'spotify'}), {
53+
seed: {
54+
legalInfo: {
55+
privacy: {url: 'https://example.com/privacy?lang=de'}
56+
}
57+
}
58+
}
59+
);
60+
61+
expect(result.current.url)
62+
.toEqual('https://example.com/privacy?lang=de&vendors=spotify#consent');
63+
});
64+
65+
it('preserves protocol-relative url format', () => {
66+
const {result} = renderHookInEntry(
67+
() => usePrivacyLink({vendors: 'spotify'}), {
68+
seed: {
69+
legalInfo: {
70+
privacy: {url: '//example.com/privacy?lang=de'}
71+
}
72+
}
73+
}
74+
);
75+
76+
expect(result.current.url)
77+
.toEqual('//example.com/privacy?lang=de&vendors=spotify#consent');
78+
});
79+
80+
it('handles relative path', () => {
81+
const {result} = renderHookInEntry(
82+
() => usePrivacyLink({vendors: 'spotify'}), {
83+
seed: {
84+
legalInfo: {
85+
privacy: {url: '/privacy?lang=de'}
86+
}
87+
}
88+
}
89+
);
90+
91+
expect(result.current.url)
92+
.toEqual('/privacy?lang=de&vendors=spotify#consent');
93+
});
94+
95+
it('supports comma-separated vendor names', () => {
96+
const {result} = renderHookInEntry(
97+
() => usePrivacyLink({vendors: 'spotify,youtube'}), {
98+
seed: {
99+
legalInfo: {
100+
privacy: {url: 'https://example.com/privacy'}
101+
}
102+
}
103+
}
104+
);
105+
106+
expect(result.current.url)
107+
.toEqual('https://example.com/privacy?vendors=spotify%2Cyoutube#consent');
108+
});
109+
110+
it('returns link props with href and target', () => {
111+
const {result} = renderHookInEntry(
112+
() => usePrivacyLink(), {
113+
seed: {
114+
legalInfo: {
115+
privacy: {url: 'https://example.com/privacy'}
116+
}
117+
}
118+
}
119+
);
120+
121+
expect(result.current.props).toEqual({
122+
href: 'https://example.com/privacy',
123+
target: '_blank',
124+
rel: 'noreferrer noopener'
125+
});
126+
});
127+
128+
it('returns props with onClick for javascript: privacy settings url', () => {
129+
const {result} = renderHookInEntry(
130+
// eslint-disable-next-line no-script-url
131+
() => usePrivacyLink(), {
132+
seed: {
133+
legalInfo: {
134+
privacy: {url: 'javascript:pageflowDisplayPrivacySettings()'}
135+
}
136+
}
137+
}
138+
);
139+
140+
expect(result.current.props.href).toEqual('#privacySettings');
141+
expect(result.current.props.onClick).toBeInstanceOf(Function);
142+
expect(result.current.props.target).toBeUndefined();
143+
});
144+
145+
it('returns link props with vendors param in href', () => {
146+
const {result} = renderHookInEntry(
147+
() => usePrivacyLink({vendors: 'spotify'}), {
148+
seed: {
149+
legalInfo: {
150+
privacy: {url: 'https://example.com/privacy'}
151+
}
152+
}
153+
}
154+
);
155+
156+
expect(result.current.props).toEqual({
157+
href: 'https://example.com/privacy?vendors=spotify#consent',
158+
target: '_blank',
159+
rel: 'noreferrer noopener'
160+
});
161+
});
162+
});

entry_types/scrolled/package/spec/widgets/defaultNavigation/LegalInfoLink-spec.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ describe('LegalInfoLink', () => {
1717
expect(getByRole('link')).toHaveAttribute('rel', 'noreferrer noopener');
1818
});
1919

20-
it('supports javascript url scheme', () => {
20+
it('allows overriding link props', () => {
2121
const {getByRole} = render(
22-
// eslint-disable-next-line no-script-url
23-
<LegalInfoLink label="Copyright" url="javascript:pageflowDisplayPrivacySettings()" />
24-
)
22+
<LegalInfoLink label="Privacy"
23+
url="https://example.com"
24+
props={{href: '#privacySettings', onClick: jest.fn()}} />
25+
);
2526

26-
expect(getByRole('link')).toHaveTextContent('Copyright');
27+
expect(getByRole('link')).toHaveTextContent('Privacy');
2728
expect(getByRole('link')).toHaveAttribute('href', '#privacySettings');
2829
expect(getByRole('link')).not.toHaveAttribute('target');
2930
expect(getByRole('link')).not.toHaveAttribute('rel');

entry_types/scrolled/package/src/frontend/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export {useFloatingPortalRoot, FloatingPortalRootProvider} from './FloatingPorta
124124
export {SectionIntersectionObserver} from './SectionIntersectionObserver';
125125
export {ScrollButton} from './ScrollButton';
126126

127+
export {usePrivacyLink} from './usePrivacyLink';
127128
export {textColorForBackgroundColor} from './textColorForBackgroundColor';
128129
export {getTransitionNames, getAvailableTransitionNames} from './transitions';
129130
export {getAppearanceSectionScopeName} from './appearance';

entry_types/scrolled/package/src/frontend/thirdPartyConsent/OptIn.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {useConsentGiven} from './hooks';
33
import {useI18n} from '../i18n';
44
import {useContentElementAttributes} from '../useContentElementAttributes';
55
import {useContentElementConsentVendor} from '../../entryState';
6+
import {usePrivacyLink} from '../usePrivacyLink';
67

78
import styles from './OptIn.module.css';
89
import OptInIcon from '../icons/media.svg';
@@ -29,9 +30,10 @@ export function OptIn({children, providerName, wrapper, icon}) {
2930
const {t} = useI18n();
3031
const {contentElementId} = useContentElementAttributes();
3132
const contentElementConsentVendor = useContentElementConsentVendor({contentElementId});
32-
3333
providerName = providerName || contentElementConsentVendor?.name;
3434

35+
const privacyLink = usePrivacyLink({vendors: providerName});
36+
3537
const cookieMessage =
3638
contentElementConsentVendor?.optInPrompt ||
3739
t(`pageflow_scrolled.public.third_party_consent.opt_in_prompt.${providerName}`);
@@ -55,6 +57,12 @@ export function OptIn({children, providerName, wrapper, icon}) {
5557
<div className={styles.optInMessage}>
5658
{cookieMessage}
5759
</div>
60+
{privacyLink.url &&
61+
<div className={styles.privacyLink}>
62+
<a {...privacyLink.props}>
63+
{privacyLink.label}
64+
</a>
65+
</div>}
5866
<div>
5967
<button className={styles.optInButton} onClick={accept}>
6068
{t('pageflow_scrolled.public.third_party_consent.confirm')}

entry_types/scrolled/package/src/frontend/thirdPartyConsent/OptIn.module.css

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
}
1717

1818
.optInMessage {
19-
margin: 1em 0 1.5em 0;
19+
margin: 1em 0;
2020
}
2121

2222
@media (max-width: 600px) {
@@ -25,7 +25,7 @@
2525
}
2626

2727
.optInMessage {
28-
margin: 0.5em 0 1em 0;
28+
margin: 0.5em 0 0.8em 0;
2929
}
3030
}
3131

@@ -43,3 +43,11 @@
4343
border-radius: 4px;
4444
cursor: pointer;
4545
}
46+
47+
.privacyLink {
48+
margin-bottom: 1.5em;
49+
}
50+
51+
.privacyLink a {
52+
color: currentColor;
53+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {useLegalInfo} from '../entryState';
2+
3+
// eslint-disable-next-line no-script-url
4+
const displayPrivacySettingsUrl = 'javascript:pageflowDisplayPrivacySettings()';
5+
6+
/**
7+
* Returns the privacy link URL, label and link props. When vendors
8+
* are passed, the vendors query parameter and consent hash are
9+
* appended to the URL. Handles absolute, protocol-relative and
10+
* relative URLs.
11+
*
12+
* @param {Object} [options]
13+
* @param {string} [options.vendors] - Comma-separated vendor names.
14+
*
15+
* @example
16+
*
17+
* const {url, label, props} = usePrivacyLink({vendors: 'youtube'});
18+
* // props => {href: '...', target: '_blank', rel: 'noreferrer noopener'}
19+
*/
20+
export function usePrivacyLink({vendors} = {}) {
21+
const {privacy} = useLegalInfo();
22+
23+
const url = vendors && privacy.url
24+
? appendVendorsParam(privacy.url, vendors)
25+
: privacy.url;
26+
27+
return {
28+
url,
29+
label: privacy.label,
30+
props: linkProps(url)
31+
};
32+
}
33+
34+
function linkProps(url) {
35+
if (url === displayPrivacySettingsUrl) {
36+
return {
37+
href: '#privacySettings',
38+
onClick(event) {
39+
window.pageflowDisplayPrivacySettings();
40+
event.preventDefault();
41+
}
42+
};
43+
}
44+
45+
return {
46+
href: url,
47+
target: '_blank',
48+
rel: 'noreferrer noopener'
49+
};
50+
}
51+
52+
function appendVendorsParam(privacyLinkUrl, vendors) {
53+
const isProtocolRelative = privacyLinkUrl.startsWith('//');
54+
const urlString = isProtocolRelative ? 'https:' + privacyLinkUrl : privacyLinkUrl;
55+
const url = new URL(urlString, 'https://localhost');
56+
57+
url.searchParams.set('vendors', vendors);
58+
url.hash = '#consent';
59+
60+
if (isProtocolRelative) {
61+
return url.toString().replace('https:', '');
62+
}
63+
else if (!privacyLinkUrl.includes('://')) {
64+
return url.pathname + url.search + url.hash;
65+
}
66+
67+
return url.toString();
68+
}

0 commit comments

Comments
 (0)