Skip to content

Commit 3148dfd

Browse files
authored
Merge pull request #15521 from guardian/dina/use-call-to-action-block-element
Use `CallToActionAtomBlockElement` in Hosted Content
2 parents da093f9 + 5034238 commit 3148dfd

5 files changed

Lines changed: 227 additions & 35 deletions

File tree

dotcom-rendering/fixtures/manual/hostedArticle.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,16 @@ export const hostedArticle: FEArticle = {
12071207
html: '<p>These are some of the Parental Controls parents can find in Fortnite. To learn more about Cabined Accounts, Epic’s approach to safety and these parental tools, visit the <a href="https://safety.epicgames.com/en-US" rel="nofollow">Safety and Security center</a>.</p>',
12081208
elementId: '005f789e-5728-4663-b4bb-f3dcdc242d3f',
12091209
},
1210+
{
1211+
_type: 'model.dotcomrendering.pageElements.CallToActionAtomBlockElement',
1212+
url: 'https://safety.epicgames.com/en-US?lang=en-US',
1213+
image: 'https://media.guim.co.uk/7fe58f11470360bc9f1e4b6bbcbf45d7cf06cfcf/0_0_1300_375/1300.jpg',
1214+
label: '',
1215+
elementId: '060724f3-d79d-4905-bb24-adc91b632eae',
1216+
title: 'Epic Games Safety',
1217+
btnText: 'Learn more',
1218+
id: '5bac2f78-7e1e-43d1-9853-db883390c913',
1219+
},
12101220
],
12111221
attributes: { pinned: false, keyEvent: false, summary: false },
12121222
blockCreatedOn: 1760017525000,

dotcom-rendering/fixtures/manual/hostedVideo.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,19 @@ export const hostedVideo: FEArticle = {
4545
blocks: [
4646
{
4747
id: '5b7f28b7e4b0b69fd6c54fb0',
48-
elements: [],
48+
elements: [
49+
{
50+
_type: 'model.dotcomrendering.pageElements.CallToActionAtomBlockElement',
51+
trackingCode: 'WASI-1',
52+
url: 'https://www.wearestillin.com/contribute',
53+
image: 'https://media.guim.co.uk/ff5fc7b83674d49054ce29f138bb3e851835233e/0_307_4867_2921/4867.jpg',
54+
label: 'America is still in. Are you?',
55+
elementId: 'e8e8b0a7-3dd8-43dc-ab01-a3f782bd95e4',
56+
title: 'We Are Still In (1st CTA)',
57+
btnText: 'Commit to climate action',
58+
id: 'fd0fc947-f32c-4d71-a59a-ebddd49c3167',
59+
},
60+
],
4961
attributes: { pinned: false, keyEvent: false, summary: false },
5062
blockCreatedOn: 1535552321000,
5163
blockCreatedOnDisplay: '15.18 BST',
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { render } from '@testing-library/react';
2+
import '@testing-library/jest-dom';
3+
import { CallToActionAtom } from './CallToActionAtom';
4+
5+
describe('CallToActionAtom', () => {
6+
it('should render with url and button text', () => {
7+
const { getByRole } = render(
8+
<CallToActionAtom
9+
linkUrl="https://example.com"
10+
backgroundImage="https://example.com/image.jpg"
11+
buttonText="Click here"
12+
/>,
13+
);
14+
15+
const link = getByRole('link');
16+
expect(link).toBeInTheDocument();
17+
expect(link).toHaveAttribute('href', 'https://example.com');
18+
19+
const button = getByRole('button', { name: 'Click here' });
20+
expect(button).toBeInTheDocument();
21+
});
22+
23+
it('should display the label when provided', () => {
24+
const { getByRole } = render(
25+
<CallToActionAtom
26+
linkUrl="https://example.com"
27+
backgroundImage="https://example.com/image.jpg"
28+
text="Label"
29+
buttonText="Click here"
30+
/>,
31+
);
32+
33+
const heading = getByRole('heading', { name: 'Label' });
34+
expect(heading).toBeInTheDocument();
35+
});
36+
37+
it('should not display a label when not provided', () => {
38+
const { queryByRole } = render(
39+
<CallToActionAtom
40+
linkUrl="https://example.com"
41+
backgroundImage="https://example.com/image.jpg"
42+
buttonText="Click here"
43+
/>,
44+
);
45+
46+
const heading = queryByRole('heading');
47+
expect(heading).not.toBeInTheDocument();
48+
});
49+
50+
it('should have correct link wrapping the entire component', () => {
51+
const { getByRole } = render(
52+
<CallToActionAtom
53+
linkUrl="https://example.com"
54+
buttonText="Learn more"
55+
text="Important Info"
56+
backgroundImage="https://example.com/image.jpg"
57+
/>,
58+
);
59+
60+
const link = getByRole('link');
61+
expect(link).toHaveAttribute('href', 'https://example.com');
62+
63+
// Check that the button is within the link
64+
const button = getByRole('button', { name: 'Learn more' });
65+
expect(link).toContainElement(button);
66+
});
67+
});

dotcom-rendering/src/components/CallToActionAtom.tsx

Lines changed: 108 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,121 @@ import { css } from '@emotion/react';
22
import {
33
from,
44
palette as sourcePalette,
5-
textSansBold20,
5+
space,
6+
textSansBold24,
7+
textSansBold28,
68
} from '@guardian/source/foundations';
79
import { Button, SvgExternal } from '@guardian/source/react-components';
10+
import { transparentColour } from '../lib/transparentColour';
811

9-
type Props = {
12+
type CallToActionProps = {
1013
linkUrl: string;
11-
backgroundImage: string;
12-
text: string;
13-
buttonText: string;
14+
backgroundImage?: string;
15+
text?: string;
16+
buttonText?: string;
1417
};
1518

19+
const overlayMaskGradientStyles = (angle: string, startPosition: number) => {
20+
const positions = [0, 8, 16, 24, 32, 40, 48, 56, 64].map(
21+
(offset) => startPosition + offset,
22+
);
23+
return css`
24+
mask-image: linear-gradient(
25+
${angle},
26+
transparent ${positions[0]}px,
27+
rgba(0, 0, 0, 0.0381) ${positions[1]}px,
28+
rgba(0, 0, 0, 0.1464) ${positions[2]}px,
29+
rgba(0, 0, 0, 0.3087) ${positions[3]}px,
30+
rgba(0, 0, 0, 0.5) ${positions[4]}px,
31+
rgba(0, 0, 0, 0.6913) ${positions[5]}px,
32+
rgba(0, 0, 0, 0.8536) ${positions[6]}px,
33+
rgba(0, 0, 0, 0.9619) ${positions[7]}px,
34+
rgb(0, 0, 0) ${positions[8]}px
35+
);
36+
`;
37+
};
38+
39+
const blurStyles = css`
40+
position: absolute;
41+
inset: 0;
42+
backdrop-filter: blur(12px) brightness(0.5);
43+
@supports not (backdrop-filter: blur(12px)) {
44+
background-color: ${transparentColour(sourcePalette.neutral[10], 0.7)};
45+
}
46+
${overlayMaskGradientStyles('180deg', 0)};
47+
48+
${from.mobileLandscape} {
49+
${overlayMaskGradientStyles('180deg', 20)};
50+
}
51+
52+
${from.tablet} {
53+
${overlayMaskGradientStyles('180deg', 80)};
54+
}
55+
56+
${from.desktop} {
57+
${overlayMaskGradientStyles('180deg', 100)};
58+
}
59+
60+
${from.leftCol} {
61+
${overlayMaskGradientStyles('180deg', 210)};
62+
}
63+
`;
64+
65+
const buttonWrapperStyles = css`
66+
${blurStyles}
67+
display: flex;
68+
position: absolute;
69+
flex-direction: column;
70+
justify-content: end;
71+
align-items: center;
72+
padding: 0 ${space[2]}px ${space[6]}px;
73+
bottom: 0;
74+
left: 0;
75+
right: 0;
76+
77+
button {
78+
width: 100%;
79+
80+
${from.tablet} {
81+
width: auto;
82+
}
83+
}
84+
85+
${from.tablet} {
86+
flex-direction: row;
87+
justify-content: start;
88+
align-items: flex-end;
89+
padding: ${space[5]}px;
90+
}
91+
92+
${from.desktop} {
93+
padding: ${space[6]}px;
94+
}
95+
`;
96+
97+
const textStyles = css`
98+
${textSansBold24}
99+
width: 100%;
100+
margin-bottom: ${space[5]}px;
101+
color: ${sourcePalette.neutral[100]};
102+
103+
${from.tablet} {
104+
${textSansBold28}
105+
margin: 0;
106+
margin-right: ${space[5]}px;
107+
}
108+
109+
${from.desktop} {
110+
width: auto;
111+
}
112+
`;
113+
16114
export const CallToActionAtom = ({
17115
linkUrl,
18116
backgroundImage,
19117
text,
20118
buttonText,
21-
}: Props) => {
119+
}: CallToActionProps) => {
22120
return (
23121
<a
24122
href={linkUrl}
@@ -48,31 +146,16 @@ export const CallToActionAtom = ({
48146
}
49147
`}
50148
/>
51-
<div
52-
css={css`
53-
position: absolute;
54-
bottom: 10%;
55-
left: 10%;
56-
transform: translate(-10%, -10%);
57-
`}
58-
>
59-
<h2
60-
css={css`
61-
${textSansBold20}
62-
margin-bottom: 8px;
63-
color: white;
64-
`}
65-
>
66-
{text}
67-
</h2>
149+
<div css={buttonWrapperStyles}>
150+
{!!text && <h2 css={textStyles}>{text}</h2>}
68151
<Button
69152
iconSide="right"
70153
size="small"
71154
icon={<SvgExternal />}
72155
theme={{
73156
textPrimary: sourcePalette.neutral[7],
74-
backgroundPrimary: sourcePalette.neutral[97],
75-
backgroundPrimaryHover: sourcePalette.neutral[73],
157+
backgroundPrimary: sourcePalette.neutral[100],
158+
backgroundPrimaryHover: sourcePalette.neutral[86],
76159
}}
77160
>
78161
{buttonText}

dotcom-rendering/src/layouts/HostedArticleLayout.tsx

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { getContributionsServiceUrl } from '../lib/contributions';
2323
import { decideMainMediaCaption } from '../lib/decide-caption';
2424
import { palette } from '../palette';
2525
import type { Article } from '../types/article';
26+
import type { Block } from '../types/blocks';
2627
import type { RenderingTarget } from '../types/renderingTarget';
2728
import { Stuck } from './lib/stickiness';
2829

@@ -192,6 +193,23 @@ export const HostedArticleLayout = (props: WebProps | AppProps) => {
192193
const { branding } =
193194
frontendData.commercialProperties[frontendData.editionId];
194195

196+
//The CTA block element is rendered separately at the end of the article body because otherwise we won't be able to have it at the end of the page.
197+
const cta = frontendData.blocks[0]?.elements.find(
198+
(element) =>
199+
element._type ===
200+
'model.dotcomrendering.pageElements.CallToActionAtomBlockElement',
201+
);
202+
203+
//We need to remove the CTA block element from the blocks that are rendered in the article body, otherwise it will be rendered twice.
204+
const blocks: Block[] = frontendData.blocks.map((block) => ({
205+
...block,
206+
elements: block.elements.filter(
207+
(element) =>
208+
element._type !==
209+
'model.dotcomrendering.pageElements.CallToActionAtomBlockElement',
210+
),
211+
}));
212+
195213
return (
196214
<>
197215
{branding ? (
@@ -284,7 +302,7 @@ export const HostedArticleLayout = (props: WebProps | AppProps) => {
284302
<ArticleContainer format={format}>
285303
<ArticleBody
286304
format={format}
287-
blocks={frontendData.blocks}
305+
blocks={blocks}
288306
editionId={frontendData.editionId}
289307
host={frontendData.config.host}
290308
pageId={frontendData.pageId}
@@ -325,14 +343,16 @@ export const HostedArticleLayout = (props: WebProps | AppProps) => {
325343
{'Placeholder - onward content'}
326344
</div>
327345

328-
<div css={ctaStyles}>
329-
<CallToActionAtom
330-
linkUrl="https://safety.epicgames.com/en-US?lang=en-US"
331-
backgroundImage="https://media.guim.co.uk/7fe58f11470360bc9f1e4b6bbcbf45d7cf06cfcf/0_0_1300_375/1300.jpg"
332-
text="This is a call to action text"
333-
buttonText="Learn more"
334-
/>
335-
</div>
346+
{cta && (
347+
<div css={ctaStyles}>
348+
<CallToActionAtom
349+
linkUrl={cta.url}
350+
backgroundImage={cta.image}
351+
text={cta.label}
352+
buttonText={cta.btnText}
353+
/>
354+
</div>
355+
)}
336356
</div>
337357
</article>
338358
</main>

0 commit comments

Comments
 (0)