Files
emaui/libs/auth/src/lib/pages/SignupPage.tsx
2026-07-29 16:53:50 +03:00

259 lines
7.5 KiB
TypeScript

import { useState } from 'react';
import {
Alert,
Anchor,
Button,
Checkbox,
Group,
PasswordInput,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import {
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 { useApiMutation } from '@ema-platform/api';
import { notify } 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';
const schema = z
.object({
email: z.string().email(),
username: z.string().min(3, { message: 'Username must be at least 3 characters' }),
phoneNumber: z.string().min(1, { message: 'Phone number is required' }),
userType: z.literal('individual'),
nameEn: z.string().min(1, { message: 'Name (English) is required' }),
nameAm: z.string().optional(),
password: z.string().min(8, { message: 'Password must be at least 8 characters' }),
confirmPassword: z.string().min(8, { message: 'Confirm your password' }),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type FormValues = z.infer<typeof schema>;
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 { appName, loginRedirectPath } = useAuthConfig();
const [agreed, setAgreed] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
token: string;
refreshToken: string;
isPhoneNumberVerified: boolean;
}>();
const [meTrigger] = useApiMutation<AuthUser>();
const {
register,
handleSubmit,
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) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
}
};
return (
<AuthShell
brandTitle={`Join ${appName}'s community.`}
brandSubtitle={`Create your account to access ${appName} features.`}
>
<Stack gap="lg">
<div>
<Title order={2} fz={30}>
Create account
</Title>
<Text c="dimmed" mt={6}>
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="Name (English)"
placeholder="Abebe Bekele"
leftSection={<IconUser size={18} />}
error={errors.nameEn?.message}
{...register('nameEn')}
/>
<TextInput
label="Name (Amharic)"
placeholder="ስም"
leftSection={<IconUser size={18} />}
error={errors.nameAm?.message}
{...register('nameAm')}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<TextInput
label="Email address"
placeholder="you@example.com"
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...register('email')}
/>
<TextInput
label="Username"
placeholder="Choose a username"
leftSection={<IconAt size={18} />}
error={errors.username?.message}
{...register('username')}
/>
</SimpleGrid>
<TextInput
label="Phone number"
placeholder="+251 911 234 567"
leftSection={<IconDeviceMobile size={18} />}
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<PasswordInput
label="Password"
placeholder="At least 8 characters"
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register('password')}
/>
<PasswordInput
label="Confirm password"
placeholder="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">
I agree to the{' '}
<Anchor
size="sm"
fw={600}
onClick={(e) => e.preventDefault()}
>
Terms &amp; Privacy Policy
</Anchor>
</Text>
}
/>
<Button
type="submit"
loading={loading}
disabled={!agreed}
fullWidth
size="md"
rightSection={<IconArrowRight size={18} />}
>
Create account
</Button>
</Stack>
</form>
<Text ta="center" size="sm" c="dimmed">
Already have an account?{' '}
<Anchor component={Link} to="/login" fw={700}>
Sign in
</Anchor>
</Text>
</Stack>
</AuthShell>
);
}