(frontend) add email validation pages

This commit is contained in:
Anthony LC
2026-01-15 16:30:47 +01:00
committed by Sylvain Boissel
parent 7e1d8111b3
commit a5111809bb
9 changed files with 318 additions and 0 deletions
@@ -0,0 +1,62 @@
import { render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import React from 'react';
import { describe, expect, test } from 'vitest';
import { AppWrapper } from '@/tests/utils';
import { UserReconciliation } from '../components/UserReconciliation';
describe('UserReconciliation', () => {
beforeEach(() => {
fetchMock.reset();
});
['active', 'inactive'].forEach((type) => {
test(`renders when reconciliation is a ${type} success`, async () => {
fetchMock.get(
`http://test.jest/api/v1.0/user_reconciliations/${type}/123456/`,
{ details: 'Success' },
);
render(
<UserReconciliation
type={type as 'active' | 'inactive'}
reconciliationId="123456"
/>,
{
wrapper: AppWrapper,
},
);
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(
await screen.findByText(/Email validated successfully !/i),
).toBeInTheDocument();
});
});
test('renders when reconciliation fails', async () => {
fetchMock.get(
`http://test.jest/api/v1.0/user_reconciliations/active/invalid-id/`,
{
throws: new Error('invalid id'),
},
);
render(<UserReconciliation type="active" reconciliationId="invalid-id" />, {
wrapper: AppWrapper,
});
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(
await screen.findByText(/An error occurred during email validation./i),
).toBeInTheDocument();
});
});
@@ -1,2 +1,3 @@
export * from './useAuthQuery';
export * from './useUserReconciliations';
export * from './types';
@@ -0,0 +1,51 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
type UserReconciliationResponse = {
details: string;
};
interface UserReconciliationProps {
type: 'active' | 'inactive';
reconciliationId?: string;
}
export const userReconciliations = async ({
type,
reconciliationId,
}: UserReconciliationProps): Promise<UserReconciliationResponse> => {
const response = await fetchAPI(
`user_reconciliations/${type}/${reconciliationId}/`,
);
if (!response.ok) {
throw new APIError(
'Failed to do the user reconciliation',
await errorCauses(response),
);
}
return response.json() as Promise<UserReconciliationResponse>;
};
export const KEY_USER_RECONCILIATIONS = 'user_reconciliations';
export function useUserReconciliationsQuery(
param: UserReconciliationProps,
queryConfig?: UseQueryOptions<
UserReconciliationResponse,
APIError,
UserReconciliationResponse
>,
) {
return useQuery<
UserReconciliationResponse,
APIError,
UserReconciliationResponse
>({
queryKey: [KEY_USER_RECONCILIATIONS, param],
queryFn: () => userReconciliations(param),
...queryConfig,
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

@@ -0,0 +1,104 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import error_img from '@/assets/icons/error-planetes.png';
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
import { useUserReconciliationsQuery } from '../api';
import success_gif from '../assets/rocket.gif';
const StyledButton = styled(Button)`
width: fit-content;
`;
interface UserReconciliationProps {
type: 'active' | 'inactive';
reconciliationId: string;
}
export const UserReconciliation = ({
type,
reconciliationId,
}: UserReconciliationProps) => {
const { t } = useTranslation();
const { data: userReconciliations, isError } = useUserReconciliationsQuery({
type,
reconciliationId,
});
if (!userReconciliations && !isError) {
return (
<Loading
$height="100vh"
$width="100vw"
$position="absolute"
$css="top: 0;"
/>
);
}
let render = (
<>
<Image src={success_gif} alt="" unoptimized priority />
<Text
as="p"
$textAlign="center"
$maxWidth="350px"
$theme="neutral"
$margin="0"
>
{t('Email validated successfully !')}
</Text>
</>
);
if (isError) {
render = (
<>
<Image
src={error_img}
alt=""
width={300}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Text
as="p"
$textAlign="center"
$maxWidth="350px"
$theme="neutral"
$margin="0"
>
{t('An error occurred during email validation.')}
</Text>
</>
);
}
return (
<Box $align="center" $margin="auto" $gap="md" $padding={{ bottom: '2rem' }}>
{render}
<Box $direction="row" $gap="sm">
<StyledLink href="/">
<StyledButton
color="neutral"
icon={
<Icon
iconName="house"
variant="symbols-outlined"
$withThemeInherited
/>
}
>
{t('Home')}
</StyledButton>
</StyledLink>
</Box>
</Box>
);
};
@@ -1,3 +1,4 @@
export * from './Auth';
export * from './ButtonLogin';
export * from './UserAvatar';
export * from './UserReconciliation';
@@ -0,0 +1,40 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { UserReconciliation } from '@/features/auth/components/UserReconciliation';
import { PageLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const {
query: { id },
} = useRouter();
if (typeof id !== 'string') {
return null;
}
return (
<>
<Head>
<meta name="robots" content="noindex" />
<title>{`${t('User reconciliation')} - ${t('Docs')}`}</title>
<meta
property="og:title"
content={`${t('User reconciliation')} - ${t('Docs')}`}
key="title"
/>
</Head>
<UserReconciliation type="active" reconciliationId={id} />
</>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
};
export default Page;
@@ -0,0 +1,40 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { UserReconciliation } from '@/features/auth/components/UserReconciliation';
import { PageLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const {
query: { id },
} = useRouter();
if (typeof id !== 'string') {
return null;
}
return (
<>
<Head>
<meta name="robots" content="noindex" />
<title>{`${t('User reconciliation')} - ${t('Docs')}`}</title>
<meta
property="og:title"
content={`${t('User reconciliation')} - ${t('Docs')}`}
key="title"
/>
</Head>
<UserReconciliation type="inactive" reconciliationId={id} />
</>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
};
export default Page;
+19
View File
@@ -1,4 +1,23 @@
import '@testing-library/jest-dom/vitest';
import * as dotenv from 'dotenv';
import React from 'react';
import { vi } from 'vitest';
dotenv.config({ path: './.env.test', quiet: true });
vi.mock('next/image', () => ({
__esModule: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any) => {
const {
src,
alt = '',
unoptimized: _unoptimized,
priority: _priority,
...rest
} = props;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const resolved = typeof src === 'string' ? src : src?.src;
return React.createElement('img', { src: resolved, alt, ...rest });
},
}));