🎨(frontend) add dsfr proconnect homepage

If we are with the DSFR theme, we need to add the
proconnect button to the homepage.
We add an option in the cunningham theme to
display the proconnect section instead of the
opensource section.
This commit is contained in:
Anthony LC
2025-02-03 11:02:29 +01:00
committed by Anthony LC
parent 4bb9c092cb
commit 1aa4844eeb
18 changed files with 696 additions and 324 deletions

View File

@@ -1,6 +1,17 @@
import { Page, expect } from '@playwright/test'; import { Page, expect } from '@playwright/test';
export const keyCloakSignIn = async (page: Page, browserName: string) => { export const keyCloakSignIn = async (
page: Page,
browserName: string,
fromHome: boolean = true,
) => {
if (fromHome) {
await page
.getByRole('button', { name: 'Proconnect Login' })
.first()
.click();
}
const login = `user-e2e-${browserName}`; const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`; const password = `password-e2e-${browserName}`;
@@ -258,3 +269,8 @@ export const mockedAccesses = async (page: Page, json?: object) => {
} }
}); });
}; };
export const expectLoginPage = async (page: Page) =>
await expect(
page.getByRole('heading', { name: 'Collaborative writing' }),
).toBeVisible();

View File

@@ -63,27 +63,6 @@ test.describe('Config', () => {
expect((await consoleMessage).text()).toContain(invalidMsg); expect((await consoleMessage).text()).toContain(invalidMsg);
}); });
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
test('it checks that media server is configured from config endpoint', async ({ test('it checks that media server is configured from config endpoint', async ({
page, page,
browserName, browserName,
@@ -161,3 +140,28 @@ test.describe('Config', () => {
).toBeVisible(); ).toBeVisible();
}); });
}); });
test.describe('Config: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
});

View File

@@ -24,8 +24,6 @@ test.describe('Doc Create', () => {
const header = page.locator('header').first(); const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click(); await header.locator('h2').getByText('Docs').click();
await expect(page.getByTestId('grid-loader')).toBeVisible();
const docsGrid = page.getByTestId('docs-grid'); const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible(); await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden(); await expect(page.getByTestId('grid-loader')).toBeHidden();

View File

@@ -213,7 +213,6 @@ test.describe('Document grid item options', () => {
test.describe('Documents filters', () => { test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => { test('it checks the prebuild left panel filters', async ({ page }) => {
// All Docs // All Docs
await expect(page.getByTestId('grid-loader')).toBeVisible();
const response = await page.waitForResponse( const response = await page.waitForResponse(
(response) => (response) =>
response.url().endsWith('documents/?page=1') && response.url().endsWith('documents/?page=1') &&
@@ -254,7 +253,6 @@ test.describe('Documents filters', () => {
url = new URL(page.url()); url = new URL(page.url());
target = url.searchParams.get('target'); target = url.searchParams.get('target');
expect(target).toBe('my_docs'); expect(target).toBe('my_docs');
await expect(page.getByTestId('grid-loader')).toBeVisible();
const responseMyDocs = await page.waitForResponse( const responseMyDocs = await page.waitForResponse(
(response) => (response) =>
response.url().endsWith('documents/?page=1&is_creator_me=true') && response.url().endsWith('documents/?page=1&is_creator_me=true') &&
@@ -270,7 +268,6 @@ test.describe('Documents filters', () => {
url = new URL(page.url()); url = new URL(page.url());
target = url.searchParams.get('target'); target = url.searchParams.get('target');
expect(target).toBe('shared_with_me'); expect(target).toBe('shared_with_me');
await expect(page.getByTestId('grid-loader')).toBeVisible();
const responseSharedWithMe = await page.waitForResponse( const responseSharedWithMe = await page.waitForResponse(
(response) => (response) =>
response.url().includes('documents/?page=1&is_creator_me=false') && response.url().includes('documents/?page=1&is_creator_me=false') &&
@@ -291,8 +288,6 @@ test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => { test('checks all the elements are visible', async ({ page }) => {
let docs: SmallDoc[] = []; let docs: SmallDoc[] = [];
await expect(page.getByTestId('grid-loader')).toBeVisible();
const response = await page.waitForResponse( const response = await page.waitForResponse(
(response) => (response) =>
response.url().endsWith('documents/?page=1') && response.url().endsWith('documents/?page=1') &&

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { keyCloakSignIn, mockedDocument } from './common'; import { expectLoginPage, keyCloakSignIn, mockedDocument } from './common';
test.describe('Doc Routing', () => { test.describe('Doc Routing', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -63,16 +63,13 @@ test.describe('Doc Routing: Not loggued', () => {
await page.goto('/docs/mocked-document-id/'); await page.goto('/docs/mocked-document-id/');
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible(); await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByRole('button', { name: 'Login' }).click(); await page.getByRole('button', { name: 'Login' }).click();
await keyCloakSignIn(page, browserName); await keyCloakSignIn(page, browserName, false);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible(); await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
}); });
// eslint-disable-next-line playwright/expect-expect
test('The homepage redirects to login.', async ({ page }) => { test('The homepage redirects to login.', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await expect( await expectLoginPage(page);
page.getByRole('button', {
name: 'Sign In',
}),
).toBeVisible();
}); });
}); });

View File

@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn, verifyDocName } from './common'; import {
createDoc,
expectLoginPage,
keyCloakSignIn,
verifyDocName,
} from './common';
const browsersName = ['chromium', 'webkit', 'firefox']; const browsersName = ['chromium', 'webkit', 'firefox'];
@@ -91,7 +96,7 @@ test.describe('Doc Visibility: Restricted', () => {
}) })
.click(); .click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); await expectLoginPage(page);
await page.goto(urlDoc); await page.goto(urlDoc);
@@ -121,6 +126,10 @@ test.describe('Doc Visibility: Restricted', () => {
await keyCloakSignIn(page, otherBrowser!); await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs BETA' }),
).toBeVisible();
await page.goto(urlDoc); await page.goto(urlDoc);
await expect( await expect(
@@ -169,10 +178,11 @@ test.describe('Doc Visibility: Restricted', () => {
await keyCloakSignIn(page, otherBrowser!); await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc); await expect(
page.getByRole('link', { name: 'Docs Logo Docs BETA' }),
).toBeVisible();
// eslint-disable-next-line playwright/no-wait-for-timeout await page.goto(urlDoc);
await page.waitForTimeout(1000);
await verifyDocName(page, docTitle); await verifyDocName(page, docTitle);
await expect(page.getByLabel('Share button')).toBeVisible(); await expect(page.getByLabel('Share button')).toBeVisible();
@@ -247,7 +257,7 @@ test.describe('Doc Visibility: Public', () => {
}) })
.click(); .click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); await expectLoginPage(page);
await page.goto(urlDoc); await page.goto(urlDoc);
@@ -313,7 +323,7 @@ test.describe('Doc Visibility: Public', () => {
}) })
.click(); .click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); await expectLoginPage(page);
await page.goto(urlDoc); await page.goto(urlDoc);
@@ -364,7 +374,7 @@ test.describe('Doc Visibility: Authenticated', () => {
}) })
.click(); .click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); await expectLoginPage(page);
await page.goto(urlDoc); await page.goto(urlDoc);
@@ -414,6 +424,10 @@ test.describe('Doc Visibility: Authenticated', () => {
const otherBrowser = browsersName.find((b) => b !== browserName); const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!); await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs BETA' }),
).toBeVisible();
await page.goto(urlDoc); await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible(); await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
@@ -470,6 +484,10 @@ test.describe('Doc Visibility: Authenticated', () => {
const otherBrowser = browsersName.find((b) => b !== browserName); const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!); await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs BETA' }),
).toBeVisible();
await page.goto(urlDoc); await page.goto(urlDoc);
await verifyDocName(page, docTitle); await verifyDocName(page, docTitle);

View File

@@ -1,13 +1,10 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Footer', () => { test.describe('Footer', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('checks all the elements are visible', async ({ page }) => { test('checks all the elements are visible', async ({ page }) => {
await page.goto('/');
const footer = page.locator('footer').first(); const footer = page.locator('footer').first();
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible(); await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
@@ -47,12 +44,6 @@ test.describe('Footer', () => {
).toBeVisible(); ).toBeVisible();
}); });
test('checks footer is not visible on doc editor', async ({ page }) => {
await expect(page.locator('footer')).toBeVisible();
await goToGridDoc(page);
await expect(page.locator('footer')).toBeHidden();
});
const legalPages = [ const legalPages = [
{ name: 'Legal Notice', url: '/legal-notice/' }, { name: 'Legal Notice', url: '/legal-notice/' },
{ name: 'Personal data and cookies', url: '/personal-data-cookies/' }, { name: 'Personal data and cookies', url: '/personal-data-cookies/' },
@@ -60,6 +51,8 @@ test.describe('Footer', () => {
]; ];
for (const { name, url } of legalPages) { for (const { name, url } of legalPages) {
test(`checks ${name} page`, async ({ page }) => { test(`checks ${name} page`, async ({ page }) => {
await page.goto('/');
const footer = page.locator('footer').first(); const footer = page.locator('footer').first();
await footer.getByRole('link', { name }).click(); await footer.getByRole('link', { name }).click();

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { keyCloakSignIn } from './common'; import { expectLoginPage, keyCloakSignIn } from './common';
test.describe('Header', () => { test.describe('Header', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -98,6 +98,6 @@ test.describe('Header: Log out', () => {
}) })
.click(); .click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); await expectLoginPage(page);
}); });
}); });

View File

@@ -20,30 +20,33 @@ test.describe('Home page', () => {
await expect( await expect(
header.getByRole('img', { name: 'Gouvernement Logo' }), header.getByRole('img', { name: 'Gouvernement Logo' }),
).toBeVisible(); ).toBeVisible();
await expect( await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible();
header.getByRole('img', { name: 'Docs app logo' }),
).toBeVisible();
await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible(); await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible();
await expect(header.getByText('BETA')).toBeVisible(); await expect(header.getByText('BETA')).toBeVisible();
// Check the ttile and subtitle are visible // Check the titles
await expect(page.getByText('Collaborative writing made')).toBeVisible(); const h2 = page.locator('h2');
await expect(page.getByText('Collaborate and write in real')).toBeVisible();
await expect(page.getByText('An uncompromising writing')).toBeVisible();
await expect(page.getByText('Docs offers an intuitive')).toBeVisible();
await expect(page.getByText('Simple and secure')).toBeVisible();
await expect(page.getByText('Docs makes real-time')).toBeVisible();
await expect(page.getByText('Flexible export.')).toBeVisible();
await expect(page.getByText('To facilitate the circulation')).toBeVisible();
await expect(page.getByText('A new way to organize')).toBeVisible();
await expect(page.getByText('Docs transforms your')).toBeVisible();
await expect(page.getByTestId('proconnect-button')).toHaveCount(2);
// Footer - The footer is already tested in its entirety in the footer.spec.ts file
await expect(footer).toBeVisible();
await expect( await expect(
page.getByRole('link', { name: 'expand_more See more' }), h2.getByText('Collaborative writing, Simplified.'),
).toBeVisible(); ).toBeVisible();
await expect(
h2.getByText('An uncompromising writing experience.'),
).toBeVisible();
await expect(
h2.getByText('Simple and secure collaboration.'),
).toBeVisible();
await expect(h2.getByText('Flexible export.')).toBeVisible();
await expect(
h2.getByText('A new way to organize knowledge.'),
).toBeVisible();
await expect(
page.getByText('Docs is already available, log in to use it now.'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Proconnect Login' }),
).toHaveCount(2);
await expect(footer).toBeVisible();
}); });
}); });

View File

@@ -253,6 +253,9 @@ const config = {
'la-gauffre': { 'la-gauffre': {
activated: false, activated: false,
}, },
'home-proconnect': {
activated: false,
},
}, },
}, },
dsfr: { dsfr: {
@@ -468,6 +471,9 @@ const config = {
'la-gauffre': { 'la-gauffre': {
activated: true, activated: true,
}, },
'home-proconnect': {
activated: true,
},
}, },
}, },
}, },

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -345,6 +345,7 @@
--c--components--button--disabled--color: white; --c--components--button--disabled--color: white;
--c--components--button--disabled--background--color: #b3cef0; --c--components--button--disabled--background--color: #b3cef0;
--c--components--la-gauffre--activated: false; --c--components--la-gauffre--activated: false;
--c--components--home-proconnect--activated: false;
} }
.cunningham-theme--dark { .cunningham-theme--dark {
@@ -590,6 +591,7 @@
); );
--c--components--forms-textarea--border-radius: 0; --c--components--forms-textarea--border-radius: 0;
--c--components--la-gauffre--activated: true; --c--components--la-gauffre--activated: true;
--c--components--home-proconnect--activated: true;
} }
.clr-secondary-text { .clr-secondary-text {

View File

@@ -336,6 +336,7 @@ export const tokens = {
disabled: { color: 'white', background: { color: '#b3cef0' } }, disabled: { color: 'white', background: { color: '#b3cef0' } },
}, },
'la-gauffre': { activated: false }, 'la-gauffre': { activated: false },
'home-proconnect': { activated: false },
}, },
}, },
dark: { dark: {
@@ -581,6 +582,7 @@ export const tokens = {
}, },
'forms-textarea': { 'border-radius': '0' }, 'forms-textarea': { 'border-radius': '0' },
'la-gauffre': { activated: true }, 'la-gauffre': { activated: true },
'home-proconnect': { activated: true },
}, },
}, },
}, },

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,6 +1,11 @@
import { Button } from '@openfun/cunningham-react'; import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { BoxButton } from '@/components';
import ProConnectImg from '../assets/button-proconnect.svg?url';
import { useAuth } from '../hooks'; import { useAuth } from '../hooks';
import { gotoLogin, gotoLogout } from '../utils'; import { gotoLogin, gotoLogout } from '../utils';
@@ -22,3 +27,22 @@ export const ButtonLogin = () => {
</Button> </Button>
); );
}; };
export const ProConnectButton = () => {
const { t } = useTranslation();
return (
<BoxButton
onClick={gotoLogin}
aria-label={t('Proconnect Login')}
$css={css`
background-color: var(--c--theme--colors--primary-text);
&:hover {
background-color: var(--c--theme--colors--primary-action);
}
`}
>
<Image src={ProConnectImg} alt={t('ProConnect Image')} />
</BoxButton>
);
};

View File

@@ -6,7 +6,7 @@ import { css } from 'styled-components';
import DocLogo from '@/assets/icons/icon-docs.svg?url'; import DocLogo from '@/assets/icons/icon-docs.svg?url';
import { Box, Icon, Text } from '@/components'; import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { gotoLogin } from '@/features/auth'; import { ProConnectButton, gotoLogin } from '@/features/auth';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import banner from '../assets/banner.jpg'; import banner from '../assets/banner.jpg';
@@ -15,9 +15,10 @@ import { getHeaderHeight } from './HomeHeader';
export default function HomeBanner() { export default function HomeBanner() {
const { t } = useTranslation(); const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme(); const { componentTokens, spacingsTokens } = useCunninghamTheme();
const spacings = spacingsTokens(); const spacings = spacingsTokens();
const { isMobile, isSmallMobile } = useResponsiveStore(); const { isMobile, isSmallMobile } = useResponsiveStore();
const withProConnect = componentTokens()['home-proconnect'].activated;
return ( return (
<Box <Box
@@ -69,16 +70,20 @@ export default function HomeBanner() {
'Collaborate and write in real time, without layout constraints.', 'Collaborate and write in real time, without layout constraints.',
)} )}
</Text> </Text>
<Button {withProConnect ? (
onClick={gotoLogin} <ProConnectButton />
icon={ ) : (
<Text $isMaterialIcon $color="white"> <Button
bolt onClick={gotoLogin}
</Text> icon={
} <Text $isMaterialIcon $color="white">
> bolt
{t('Start Writing')} </Text>
</Button> }
>
{t('Start Writing')}
</Button>
)}
</Box> </Box>
{!isMobile && ( {!isMobile && (
<Image <Image

View File

@@ -1,20 +1,38 @@
import Image from 'next/image';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
import DocLogo from '@/assets/icons/icon-docs.svg?url';
import { Box, Text } from '@/components'; import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { ProConnectButton } from '@/features/auth';
import { Title } from '@/features/header';
import { useResponsiveStore } from '@/stores';
import SC5 from '../assets/SC5.png'; import SC5 from '../assets/SC5.png';
import { HomeSection } from './HomeSection'; import { HomeSection } from './HomeSection';
export function HomeOpenSource() { export function HomeBottom() {
const { componentTokens } = useCunninghamTheme();
const withProConnect = componentTokens()['home-proconnect'].activated;
if (withProConnect) {
return <HomeProConnect />;
} else {
return <HomeOpenSource />;
}
}
function HomeOpenSource() {
const { t } = useTranslation(); const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme(); const { colorsTokens } = useCunninghamTheme();
const { isTablet } = useResponsiveStore();
return ( return (
<HomeSection <HomeSection
isColumn={false} isColumn={false}
isSmallDevice={isTablet}
illustration={SC5} illustration={SC5}
title={t('Govs ❤️ Open Source.')} title={t('Govs ❤️ Open Source.')}
tag={t('Open Source')} tag={t('Open Source')}
@@ -101,3 +119,41 @@ export function HomeOpenSource() {
/> />
); );
} }
function HomeProConnect() {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const { isMobile } = useResponsiveStore();
const parentGap = '230px';
return (
<Box
$justify="center"
$height={!isMobile ? `calc(100vh - ${parentGap})` : 'auto'}
>
<Box
$gap={spacings['md']}
$direction="column"
$align="center"
$margin={{ top: isMobile ? 'none' : `-${parentGap}` }}
>
<Box
$align="center"
$gap={spacings['3xs']}
$direction="row"
$position="relative"
$height="fit-content"
$css="zoom: 1.9;"
>
<Image src={DocLogo} alt="DocLogo" />
<Title />
</Box>
<Text $size="md" $variation="1000" $textAlign="center">
{t('Docs is already available, log in to use it now.')}
</Text>
<ProConnectButton />
</Box>
</Box>
);
}

View File

@@ -19,7 +19,7 @@ import SC4ResponsiveEn from '../assets/SC4-responsive-en.png';
import SC4ResponsiveFr from '../assets/SC4-responsive-fr.png'; import SC4ResponsiveFr from '../assets/SC4-responsive-fr.png';
import HomeBanner from './HomeBanner'; import HomeBanner from './HomeBanner';
import { HomeOpenSource } from './HomeOpenSource'; import { HomeBottom } from './HomeBottom';
import { HomeHeader, getHeaderHeight } from './HomeHeader'; import { HomeHeader, getHeaderHeight } from './HomeHeader';
import { HomeSection } from './HomeSection'; import { HomeSection } from './HomeSection';
@@ -110,7 +110,7 @@ export function HomeContent() {
'Docs transforms your documents into knowledge bases thanks to subpages, powerful search and the ability to pin your important documents.', 'Docs transforms your documents into knowledge bases thanks to subpages, powerful search and the ability to pin your important documents.',
)} )}
/> />
<HomeOpenSource /> <HomeBottom />
</Box> </Box>
</Box> </Box>
<Footer /> <Footer />