Skip to content

Commit

Permalink
feat(client): signup page
Browse files Browse the repository at this point in the history
  • Loading branch information
Ivo committed Apr 23, 2023
1 parent 9b03c48 commit 5e0fe33
Show file tree
Hide file tree
Showing 11 changed files with 255 additions and 25 deletions.
5 changes: 4 additions & 1 deletion apps/client/public/locales/en-US/navigation.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
"empty": "Can't be empty"
}
},
"submit": "Sign In",
"submit": {
"signin": "Sign In",
"signup": "Sign Up"
},
"lost-password": "I forgot my password",
"new-user": "Register"
},
Expand Down
18 changes: 17 additions & 1 deletion apps/client/public/locales/en-US/signin.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
{
"title": "Sign In"
"title": "Sign In",
"identifier": {
"title": "Email or Username",
"placeholder": "user@uni.edu",
"errors": {
"empty": "Can't be empty"
}
},
"password": {
"title": "Password",
"placeholder": "********",
"errors": {
"empty": "Can't be empty"
}
},
"lost-password": "I forgot my password",
"new-user": "Register"
}
31 changes: 31 additions & 0 deletions apps/client/public/locales/en-US/signup.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"title": "Sign Up",
"errors": {
"user": {
"existing": "There's a user with this email or username already."
}
},
"email": {
"title": "Email",
"placeholder": "user@uni.edu",
"errors": {
"disallowed": "You must use your institutional email",
"empty": "Can't be empty",
"invalid": "This is not a valid email address"
}
},
"password": {
"title": "Password",
"placeholder": "********",
"errors": {
"empty": "Can't be empty"
}
},
"username": {
"title": "Username",
"placeholder": "johndoe",
"errors": {
"empty": "Can't be empty"
}
}
}
28 changes: 14 additions & 14 deletions apps/client/src/components/Forms/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import { TextButton } from "@app/components/Button/Text";
import { Input } from "@app/components/Input";
import { Spinner } from "@app/components/Spinner";
import {
AuthFormSchema,
AuthFormSchemaType
} from "@app/schemas/components/auth/auth.zod";
SignInFormSchema,
SignInFormSchemaType
} from "@app/schemas/components/auth/signin.zod";

export function SignInForm() {
const { t } = useTranslation(["signin"]);
Expand All @@ -24,8 +24,8 @@ export function SignInForm() {
reset,
formState: { errors },
handleSubmit
} = useForm<AuthFormSchemaType>({
resolver: zodResolver(AuthFormSchema)
} = useForm<SignInFormSchemaType>({
resolver: zodResolver(SignInFormSchema)
});
const router = useRouter();

Expand All @@ -36,7 +36,7 @@ export function SignInForm() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status]);

const handleAuth: SubmitHandler<AuthFormSchemaType> = async ({
const handleAuth: SubmitHandler<SignInFormSchemaType> = async ({
identifier,
password
}) => {
Expand All @@ -57,14 +57,14 @@ export function SignInForm() {
>
<Input
error={errors.identifier}
label={t("navigation:forms.identifier.title")}
placeholder={t("navigation:forms.identifier.placeholder")}
label={t("signin:identifier.title")}
placeholder={t("signin:identifier.placeholder")}
{...register("identifier")}
/>
<Input
error={errors.password}
label={t("navigation:forms.password.title")}
placeholder={t("navigation:forms.password.placeholder")}
label={t("signin:password.title")}
placeholder={t("signin:password.placeholder")}
type="password"
{...register("password")}
/>
Expand All @@ -74,7 +74,7 @@ export function SignInForm() {
type="submit"
>
{isAuthenticating ? <Spinner /> : null}
{t("navigation:forms.submit")}
{t("signin:title")}
</Button>
<div className="flex gap-x-2">
<TextButton
Expand All @@ -83,16 +83,16 @@ export function SignInForm() {
LeftIcon={Lock}
type="button"
>
{t("navigation:forms.lost-password")}
{t("signin:lost-password")}
</TextButton>
<TextButton
className="text-sm"
iconClassName="h-4 w-4"
LeftIcon={UserPlus}
onClick={() => router.push("/register")}
onClick={() => router.push("/signup")}
type="button"
>
{t("navigation:forms.new-user")}
{t("signin:new-user")}
</TextButton>
</div>
</form>
Expand Down
110 changes: 110 additions & 0 deletions apps/client/src/components/Forms/SignUp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { AlertTriangle, UserPlus } from "lucide-react";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";

import { Button } from "@app/components/Button";
import { Input } from "@app/components/Input";
import { Spinner } from "@app/components/Spinner";
import {
SignUpFormSchema,
SignUpFormSchemaType
} from "@app/schemas/components/auth/signup.zod";

export function SignUpForm() {
const { t } = useTranslation(["signup"]);
const [isSigningUp, setIsSigningUp] = useState(false);
const [signUpStatus, setSignUpStatus] = useState<
| {
status: "success";
}
| {
status: "error";
errorMessage: string;
}
>();
const {
register,
reset,
formState: { errors },
handleSubmit
} = useForm<SignUpFormSchemaType>({
resolver: zodResolver(SignUpFormSchema)
});
const router = useRouter();

useEffect(() => {
if (signUpStatus?.status === "success") {
router.push("/signin");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signUpStatus]);

const handleAuth: SubmitHandler<SignUpFormSchemaType> = async ({
email,
username,
password
}) => {
setIsSigningUp(true);
axios
.post("/api/users/signup", {
email,
username,
password
})
.then(() => {
setSignUpStatus({ status: "success" });
reset();
})
.catch(({ response }) =>
setSignUpStatus({ status: "error", errorMessage: response.data.status })
)
.finally(() => setIsSigningUp(false));
};

return (
<form
className="flex flex-col gap-y-2.5 lg:mx-auto lg:w-1/2"
onSubmit={handleSubmit(handleAuth)}
>
{signUpStatus?.status === "error" ? (
<div className="flex gap-x-2 rounded-lg border border-red-500 bg-red-400/20 p-2">
<AlertTriangle className="stroke-red-600 dark:stroke-red-200" />
<p className="text-red-600 dark:text-red-200">
{t(`signup:errors.${signUpStatus.errorMessage}`)}
</p>
</div>
) : null}
<Input
error={errors.username}
label={t("signup:username.title")}
placeholder={t("signup:username.placeholder")}
{...register("username")}
/>
<Input
error={errors.email}
label={t("signup:email.title")}
placeholder={t("signup:email.placeholder")}
{...register("email")}
/>
<Input
error={errors.password}
label={t("signup:password.title")}
placeholder={t("signup:password.placeholder")}
type="password"
{...register("password")}
/>
<Button
disabled={isSigningUp}
LeftIcon={!isSigningUp ? UserPlus : undefined}
type="submit"
>
{isSigningUp ? <Spinner /> : null}
{t("signup:title")}
</Button>
</form>
);
}
2 changes: 1 addition & 1 deletion apps/client/src/components/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function Header() {
</Link>
</li>
<li className="hidden lg:block">
<Link href="/register">
<Link href="/signup">
<TextButton LeftIcon={UserPlus}>Register</TextButton>
</Link>
</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ export default async function handler(
const { email, username, password } = req.body;

try {
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ username }, { email }]
}
});

if (existingUser) {
return res.status(409).json({
status: "user.existing"
});
}

const hashedPassword = await hash(password);

const user = await prisma.user.create({
Expand Down
40 changes: 40 additions & 0 deletions apps/client/src/pages/signup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import dynamic from "next/dynamic";
import { useTranslation } from "next-i18next";

import { SEO } from "@app/components/SEO";
import { Spinner } from "@app/components/Spinner";
import { withSSRGuest } from "@app/hocs/withSSRGuest";
import { withSSRTranslations } from "@app/hocs/withSSRTranslations";

const SignUpForm = dynamic(
() => import("@app/components/Forms/SignUp").then((mod) => mod.SignUpForm),
{
loading: () => (
<div className="flex flex-1 items-center justify-center">
<Spinner />
</div>
),
ssr: false
}
);

export const getServerSideProps = withSSRTranslations(withSSRGuest(), {
namespaces: ["signup"]
});

export default function SignUp() {
const { t } = useTranslation(["signup"]);

return (
<>
<SEO
title={t("signup:title")}
description={t("signup:description")}
/>
<h2 className="text-center text-2xl font-bold uppercase text-primary-700 dark:text-primary-400">
{t("signup:title")}
</h2>
<SignUpForm />
</>
);
}
8 changes: 0 additions & 8 deletions apps/client/src/schemas/components/auth/auth.zod.ts

This file was deleted.

8 changes: 8 additions & 0 deletions apps/client/src/schemas/components/auth/signin.zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { z } from "zod";

export const SignInFormSchema = z.object({
identifier: z.string().min(1, "signin:identifier.errors.empty"),
password: z.string().min(1, "signin:password.errors.empty")
});

export type SignInFormSchemaType = z.infer<typeof SignInFormSchema>;
18 changes: 18 additions & 0 deletions apps/client/src/schemas/components/auth/signup.zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from "zod";

export const SignUpFormSchema = z.object({
username: z.string().min(1, "signup:username.errors.empty"),
email: z
.string()
.min(1, "signup:email.errors.empty")
.email("signup:email.errors.invalid")
.regex(
new RegExp(
"[a-z0-9]+@(?!gmail|yahoo|qq|yandex|hotmail|outlook|123|126|163).[a-z0-9]+"
),
"signup:email.errors.disallowed"
),
password: z.string().min(1, "signup:password.errors.empty")
});

export type SignUpFormSchemaType = z.infer<typeof SignUpFormSchema>;

0 comments on commit 5e0fe33

Please sign in to comment.