mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
323 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|