``refactor Implement custom sign-up form component with Zod validation, OAuth integration, and i18n support following REQ0016 requirements``

This commit is contained in:
2025-05-16 11:03:58 +08:00
parent 4b750b8146
commit bf059147c7

View File

@@ -0,0 +1,254 @@
'use client';
// RULES:
// refer to ticket REQ0016 for login flow
import * as React from 'react';
import RouterLink from 'next/link';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import Link from '@mui/material/Link';
import OutlinedInput from '@mui/material/OutlinedInput';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import { authClient } from '@/lib/auth/custom/client';
import { useUser } from '@/hooks/use-user';
import { DynamicLogo } from '@/components/core/logo';
import { toast } from '@/components/core/toaster';
import type { OAuthProvider } from '../OAuthProvider';
import { oAuthProviders } from '../oAuthProviders';
export function SignUpForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['sign_in']);
const { checkSession } = useUser();
const [isPending, setIsPending] = React.useState<boolean>(false);
const schema = zod.object({
firstName: zod.string().min(1, { message: t('first-name-is-required') }),
lastName: zod.string().min(1, { message: t('last-name-is-required') }),
email: zod
.string()
.min(1, { message: t('email-is-required') })
.email(),
password: zod.string().min(6, { message: t('password-should-be-at-least-6-characters') }),
terms: zod.boolean().refine((value) => value, t('you-must-accept-the-terms-and-conditions')),
});
type Values = zod.infer<typeof schema>;
const defaultValues = { firstName: '', lastName: '', email: '', password: '', terms: false } satisfies Values;
const {
control,
handleSubmit,
setError,
formState: { errors },
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onAuth = React.useCallback(async (providerId: OAuthProvider['id']): Promise<void> => {
setIsPending(true);
const { error } = await authClient.signInWithOAuth({ provider: providerId });
if (error) {
setIsPending(false);
toast.error(error);
return;
}
setIsPending(false);
// Redirect to OAuth provider
}, []);
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
setIsPending(true);
const { error } = await authClient.signUp(values);
if (error) {
setError('root', { type: 'server', message: error });
setIsPending(false);
return;
}
// Refresh the auth state
await checkSession?.();
// UserProvider, for this case, will not refresh the router
// After refresh, GuestGuard will handle the redirect
// TODO: resume me
// router.refresh();
},
[checkSession, router, setError]
);
return (
<Stack spacing={4}>
<div>
<Box
component={RouterLink}
href={paths.home}
sx={{ display: 'inline-block', fontSize: 0 }}
>
<DynamicLogo
colorDark="light"
colorLight="dark"
height={32}
width={122}
/>
</Box>
</div>
<Stack spacing={1}>
<Typography variant="h5">{t('sign-up')}</Typography>
<Typography
color="text.secondary"
variant="body2"
>
{t('already-have-an-account')}{' '}
<Link
component={RouterLink}
href={paths.auth.custom.signIn}
variant="subtitle2"
>
{t('sign-in')}
</Link>
</Typography>
</Stack>
<Stack spacing={3}>
<Stack spacing={2}>
{oAuthProviders.map(
(provider): React.JSX.Element => (
<Button
color="secondary"
disabled={isPending}
startIcon={
<Box
alt=""
component="img"
height={24}
src={provider.logo}
width={24}
/>
}
key={provider.id}
onClick={(): void => {
onAuth(provider.id).catch(() => {
// noop
});
}}
variant="outlined"
>
{t('continue-with_fh')} {provider.name} {t('continue-with_sh')}
</Button>
)
)}
</Stack>
<Divider>{t('or')}</Divider>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={2}>
<Controller
control={control}
name="firstName"
render={({ field }) => (
<FormControl error={Boolean(errors.firstName)}>
<InputLabel>{t('first-name')}:</InputLabel>
<OutlinedInput {...field} />
{errors.firstName ? <FormHelperText>{errors.firstName.message}</FormHelperText> : null}
</FormControl>
)}
/>
<Controller
control={control}
name="lastName"
render={({ field }) => (
<FormControl error={Boolean(errors.lastName)}>
<InputLabel>{t('last-name')}:</InputLabel>
<OutlinedInput {...field} />
{errors.lastName ? <FormHelperText>{errors.lastName.message}</FormHelperText> : null}
</FormControl>
)}
/>
<Controller
control={control}
name="email"
render={({ field }) => (
<FormControl error={Boolean(errors.email)}>
<InputLabel>{t('email-address')}:</InputLabel>
<OutlinedInput
{...field}
placeholder={t('e.g.') + ' name@example.com'}
type="email"
disabled={isPending}
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl>
)}
/>
<Controller
control={control}
name="password"
render={({ field }) => (
<FormControl error={Boolean(errors.password)}>
<InputLabel>{t('password')}:</InputLabel>
<OutlinedInput
{...field}
placeholder={t('password')}
type="password"
/>
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
</FormControl>
)}
/>
<Controller
control={control}
name="terms"
render={({ field }) => (
<div>
<FormControlLabel
control={<Checkbox {...field} />}
label={
<React.Fragment>
{t('i-have-read-the')} <Link>{t('terms-and-conditions')}</Link>
</React.Fragment>
}
/>
{errors.terms ? <FormHelperText error>{errors.terms.message}</FormHelperText> : null}
</div>
)}
/>
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
<LoadingButton
loading={isPending}
disabled={isPending}
type="submit"
variant="contained"
>
{t('create-account')}
</LoadingButton>
</Stack>
</form>
</Stack>
<Alert color="warning">{t('created-users-are-not-persisted')}</Alert>
</Stack>
);
}