Skip to content

Commit b3358f4

Browse files
committed
Render CTA at the end of the page and added tests
1 parent 724a0c6 commit b3358f4

File tree

5 files changed

+225
-81
lines changed

5 files changed

+225
-81
lines changed
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+
url="https://example.com"
10+
btnText="Click here"
11+
image="https://example.com/image.jpg"
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+
url="https://example.com"
27+
btnText="Click here"
28+
label="Label"
29+
image="https://example.com/image.jpg"
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+
url="https://example.com"
41+
btnText="Click here"
42+
image="https://example.com/image.jpg"
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+
url="https://example.com"
54+
btnText="Learn more"
55+
label="Important Info"
56+
image="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: 116 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,120 @@ import { css } from '@emotion/react';
22
import {
33
from,
44
palette as sourcePalette,
5-
textSansBold20,
5+
space,
6+
textSansBold28,
7+
textSansBold34,
68
} from '@guardian/source/foundations';
79
import { Button, SvgExternal } from '@guardian/source/react-components';
8-
import type { CallToActionProps } from './CallToActionAtomWrapper';
10+
import { transparentColour } from '../lib/transparentColour';
11+
12+
type CallToActionProps = {
13+
url: string;
14+
image?: string;
15+
label?: string;
16+
btnText?: string;
17+
};
918

1019
export const CallToActionAtom = ({
1120
url,
1221
image,
1322
label,
1423
btnText,
1524
}: CallToActionProps) => {
25+
const overlayMaskGradientStyles = (
26+
angle: string,
27+
startPosition: number,
28+
) => {
29+
const positions = [0, 8, 16, 24, 32, 40, 48, 56, 64].map(
30+
(offset) => startPosition + offset,
31+
);
32+
return css`
33+
mask-image: linear-gradient(
34+
${angle},
35+
transparent ${positions[0]}px,
36+
rgba(0, 0, 0, 0.0381) ${positions[1]}px,
37+
rgba(0, 0, 0, 0.1464) ${positions[2]}px,
38+
rgba(0, 0, 0, 0.3087) ${positions[3]}px,
39+
rgba(0, 0, 0, 0.5) ${positions[4]}px,
40+
rgba(0, 0, 0, 0.6913) ${positions[5]}px,
41+
rgba(0, 0, 0, 0.8536) ${positions[6]}px,
42+
rgba(0, 0, 0, 0.9619) ${positions[7]}px,
43+
rgb(0, 0, 0) ${positions[8]}px
44+
);
45+
`;
46+
};
47+
48+
const blurStyles = css`
49+
position: absolute;
50+
inset: 0;
51+
backdrop-filter: blur(12px) brightness(0.5);
52+
@supports not (backdrop-filter: blur(12px)) {
53+
background-color: ${transparentColour(
54+
sourcePalette.neutral[10],
55+
0.7,
56+
)};
57+
}
58+
${overlayMaskGradientStyles('180deg', 0)};
59+
60+
${from.mobileLandscape} {
61+
${overlayMaskGradientStyles('180deg', 20)};
62+
}
63+
64+
${from.tablet} {
65+
${overlayMaskGradientStyles('180deg', 80)};
66+
}
67+
68+
${from.desktop} {
69+
${overlayMaskGradientStyles('180deg', 100)};
70+
}
71+
72+
${from.leftCol} {
73+
${overlayMaskGradientStyles('180deg', 210)};
74+
}
75+
`;
76+
77+
const buttonWrapperStyles = css`
78+
${blurStyles}
79+
display: flex;
80+
position: absolute;
81+
flex-direction: column;
82+
justify-content: end;
83+
align-items: center;
84+
padding: 0 ${space[2]}px ${space[6]}px;
85+
bottom: 0;
86+
left: 0;
87+
right: 0;
88+
89+
${from.tablet} {
90+
flex-direction: row;
91+
align-items: flex-end;
92+
padding: ${space[8]}px ${space[9]}px;
93+
}
94+
95+
${from.desktop} {
96+
justify-content: start;
97+
padding: ${space[8]}px ${space[5]}px;
98+
}
99+
`;
100+
101+
const labelStyles = css`
102+
${textSansBold28}
103+
width: 100%;
104+
margin-bottom: ${space[5]}px;
105+
color: white;
106+
107+
${from.tablet} {
108+
${textSansBold34}
109+
margin: 0;
110+
padding-right: ${space[8]}px;
111+
}
112+
113+
${from.desktop} {
114+
width: auto;
115+
max-width: 621px;
116+
}
117+
`;
118+
16119
return (
17120
<a
18121
href={url}
@@ -42,34 +145,24 @@ export const CallToActionAtom = ({
42145
}
43146
`}
44147
/>
45-
<div
46-
css={css`
47-
position: absolute;
48-
bottom: 10%;
49-
left: 10%;
50-
transform: translate(-10%, -10%);
51-
`}
52-
>
53-
{!!label && (
54-
<h2
55-
css={css`
56-
${textSansBold20}
57-
margin-bottom: 8px;
58-
color: white;
59-
`}
60-
>
61-
{label}
62-
</h2>
63-
)}
148+
<div css={buttonWrapperStyles}>
149+
{!!label && <h2 css={labelStyles}>{label}</h2>}
64150
<Button
65151
iconSide="right"
66152
size="small"
67153
icon={<SvgExternal />}
68154
theme={{
69155
textPrimary: sourcePalette.neutral[7],
70-
backgroundPrimary: sourcePalette.neutral[97],
71-
backgroundPrimaryHover: sourcePalette.neutral[73],
156+
backgroundPrimary: sourcePalette.neutral[100],
157+
backgroundPrimaryHover: sourcePalette.neutral[86],
72158
}}
159+
cssOverrides={css`
160+
width: 100%;
161+
162+
${from.tablet} {
163+
width: auto;
164+
}
165+
`}
73166
>
74167
{btnText}
75168
</Button>

dotcom-rendering/src/components/CallToActionAtomWrapper.tsx

Lines changed: 0 additions & 46 deletions
This file was deleted.

dotcom-rendering/src/layouts/HostedArticleLayout.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { ArticleBody } from '../components/ArticleBody';
99
import { ArticleContainer } from '../components/ArticleContainer';
1010
import { ArticleHeadline } from '../components/ArticleHeadline';
11+
import { CallToActionAtom } from '../components/CallToActionAtom';
1112
import { Caption } from '../components/Caption';
1213
import { HostedContentDisclaimer } from '../components/HostedContentDisclaimer';
1314
import { HostedContentHeader } from '../components/HostedContentHeader';
@@ -22,6 +23,7 @@ import { getContributionsServiceUrl } from '../lib/contributions';
2223
import { decideMainMediaCaption } from '../lib/decide-caption';
2324
import { palette } from '../palette';
2425
import type { Article } from '../types/article';
26+
import type { Block } from '../types/blocks';
2527
import type { RenderingTarget } from '../types/renderingTarget';
2628
import { Stuck } from './lib/stickiness';
2729

@@ -142,6 +144,17 @@ const onwardContentStyles = css`
142144
margin-bottom: 24px;
143145
`;
144146

147+
const ctaStyles = css`
148+
${grid.column.all}
149+
grid-row:auto;
150+
overflow: hidden;
151+
max-height: 400px;
152+
${from.wide} {
153+
width: ${breakpoints.wide}px;
154+
margin: auto;
155+
}
156+
`;
157+
145158
const sideBorders = css`
146159
${from.desktop} {
147160
position: relative;
@@ -180,6 +193,23 @@ export const HostedArticleLayout = (props: WebProps | AppProps) => {
180193
const { branding } =
181194
frontendData.commercialProperties[frontendData.editionId];
182195

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+
183213
return (
184214
<>
185215
{branding ? (
@@ -272,7 +302,7 @@ export const HostedArticleLayout = (props: WebProps | AppProps) => {
272302
<ArticleContainer format={format}>
273303
<ArticleBody
274304
format={format}
275-
blocks={frontendData.blocks}
305+
blocks={blocks}
276306
editionId={frontendData.editionId}
277307
host={frontendData.config.host}
278308
pageId={frontendData.pageId}
@@ -312,6 +342,17 @@ export const HostedArticleLayout = (props: WebProps | AppProps) => {
312342
<div css={onwardContentStyles}>
313343
{'Placeholder - onward content'}
314344
</div>
345+
346+
{cta && (
347+
<div css={ctaStyles}>
348+
<CallToActionAtom
349+
url={cta.url}
350+
image={cta.image}
351+
label={cta.label}
352+
btnText={cta.btnText}
353+
/>
354+
</div>
355+
)}
315356
</div>
316357
</article>
317358
</main>

0 commit comments

Comments
 (0)