Files
emaui/libs/auth/src/lib/pages/SignupPage.tsx
Estifo77 9185df64b9 fixes
2026-08-19 10:31:13 +03:00

323 lines
11 KiB
TypeScript

import { useState } from 'react';
import {
Alert,
Anchor,
Button,
Checkbox,
Group,
PasswordInput,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
UnstyledButton,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconAt,
IconDeviceMobile,
IconLock,
IconMail,
IconUser,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useApiMutation } from '@ema-platform/api';
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
import { useAuthConfig } from '../AuthConfig';
interface SignupPayload {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: {
am: string;
en: string;
};
password: string;
confirmPassword: string;
}
export function SignupPage() {
const navigate = useNavigate();
const dispatch = useDispatch();
const { t } = useTranslation();
const { appName, loginRedirectPath } = useAuthConfig();
const [agreed, setAgreed] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const { handleError } = useErrorHandler();
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
token: string;
refreshToken: string;
isPhoneNumberVerified: boolean;
}>();
const [meTrigger] = useApiMutation<AuthUser>();
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
};
// Order matches passwordRules' default list: length, lowercase, uppercase,
// number, special character. Shared between the zod schema (field error)
// and the live checklist below, so both agree on the wording.
const passwordRuleLabels = [
t('signup.passwordRule.minLength', { min: 8, defaultValue: 'At least {{min}} characters' }),
t('signup.passwordRule.lowercase', 'One lowercase letter'),
t('signup.passwordRule.uppercase', 'One uppercase letter'),
t('signup.passwordRule.number', 'One number'),
t('signup.passwordRule.special', 'One special character'),
];
// Built inside the component (not module scope) so validation messages
// pick up the active language — same pattern as ProfilePage's forms.
const schema = z
.object({
email: z.string().email(),
username: z.string().min(3, { message: t('signup.usernameMinLength', 'Username must be at least 3 characters') }),
phoneNumber: z.string().min(1, { message: t('signup.phoneRequired', 'Phone number is required') }),
userType: z.literal('individual'),
nameEn: z
.string()
.min(1, { message: t('signup.nameEnRequired', 'Name (English) is required') })
.refine((v) => v.trim().split(/\s+/).length >= 3, {
message: t('signup.nameEnFullNameRequired', 'Please enter your full name (first, middle, and last)'),
}),
nameAm: z.string().optional(),
password: passwordSchema(8, passwordRuleLabels),
confirmPassword: z.string().min(1, { message: t('signup.confirmPasswordRequired', 'Confirm your password') }),
})
.refine((data) => data.password === data.confirmPassword, {
message: t('signup.passwordsDontMatch', 'Passwords do not match'),
path: ['confirmPassword'],
});
type FormValues = z.infer<typeof schema>;
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { userType: 'individual' },
});
const onSubmit = async (values: FormValues) => {
try {
const payload: SignupPayload = {
email: values.email,
username: values.username,
phoneNumber: values.phoneNumber,
userType: values.userType,
name: { en: values.nameEn, am: values.nameAm ?? '' },
password: values.password,
confirmPassword: values.confirmPassword,
};
const data = await signupTrigger({
url: '/auth/signup-with-pwd',
method: 'POST',
body: payload,
}).unwrap();
dispatch(
loginSuccess({
token: data.token,
refreshToken: data.refreshToken,
isPhoneNumberVerified: data.isPhoneNumberVerified,
}),
);
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
if (data.isPhoneNumberVerified) {
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
state: {
email: values.email,
phoneNumber: values.phoneNumber,
},
});
}
} catch (err: unknown) {
setServerError(handleError(err));
}
};
return (
<AuthShell
brandTitle={t('signup.brandTitle', { appName, defaultValue: "Join {{appName}}'s community." })}
brandSubtitle={t('signup.brandSubtitle', {
appName,
defaultValue: 'Create your account to access {{appName}} features.',
})}
>
<Stack gap="lg">
<UnstyledButton
onClick={handleBack}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
fontSize: 14,
fontWeight: 600,
color: 'var(--mantine-color-dimmed)',
cursor: 'pointer',
width: 'fit-content',
transition: 'all 150ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--mantine-primary-color-filled)';
e.currentTarget.style.transform = 'translateX(-3px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--mantine-color-dimmed)';
e.currentTarget.style.transform = 'translateX(0)';
}}
>
<IconArrowLeft size={18} />
{t('common.back', 'Back')}
</UnstyledButton>
<div>
<Title order={2} fz={30}>
{t('signup.title', 'Create account')}
</Title>
<Text c="dimmed" mt={6}>
{t('signup.subtitle', 'It only takes a minute to get started.')}
</Text>
</div>
{serverError && (
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
{serverError}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<TextInput
label={t('signup.nameEnLabel', 'Full name (English)')}
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
leftSection={<IconUser size={18} />}
error={errors.nameEn?.message}
{...register('nameEn')}
/>
<TextInput
label={t('signup.nameAmLabel', 'Name (Amharic)')}
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
leftSection={<IconUser size={18} />}
error={errors.nameAm?.message}
{...register('nameAm')}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<TextInput
label={t('signup.emailLabel', 'Email address')}
placeholder={t('signup.emailPlaceholder', 'you@example.com')}
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...register('email')}
/>
<TextInput
label={t('signup.usernameLabel', 'Username')}
placeholder={t('signup.usernamePlaceholder', 'Choose a username')}
leftSection={<IconAt size={18} />}
error={errors.username?.message}
{...register('username')}
/>
</SimpleGrid>
<TextInput
label={t('signup.phoneLabel', 'Phone number')}
placeholder={t('signup.phonePlaceholder', '+251 911 234 567')}
leftSection={<IconDeviceMobile size={18} />}
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<div>
<PasswordInput
label={t('signup.passwordLabel', 'Password')}
placeholder={t('signup.passwordPlaceholder', 'At least 8 characters')}
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register('password')}
/>
<PasswordRequirements
password={watch('password') ?? ''}
minLength={8}
labels={passwordRuleLabels}
/>
</div>
<PasswordInput
label={t('signup.confirmPasswordLabel', 'Confirm password')}
placeholder={t('signup.confirmPasswordPlaceholder', 'Re-enter password')}
leftSection={<IconLock size={18} />}
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
</SimpleGrid>
<Checkbox
size="sm"
checked={agreed}
onChange={(e) => setAgreed(e.currentTarget.checked)}
label={
<Text size="sm">
{t('signup.agreeToThe', 'I agree to the ')}
<Anchor
size="sm"
fw={600}
onClick={(e) => e.preventDefault()}
>
{t('signup.termsAndPrivacy', 'Terms & Privacy Policy')}
</Anchor>
</Text>
}
/>
<Button
type="submit"
loading={loading}
disabled={!agreed}
fullWidth
size="md"
rightSection={<IconArrowRight size={18} />}
>
{t('signup.createAccount', 'Create account')}
</Button>
</Stack>
</form>
<Text ta="center" size="sm" c="dimmed">
{t('signup.haveAccount', 'Already have an account? ')}
<Anchor component={Link} to="/login" fw={700}>
{t('signup.signIn', 'Sign in')}
</Anchor>
</Text>
</Stack>
</AuthShell>
);
}