feat: impl sign up page

This commit is contained in:
2025-12-16 00:48:06 +00:00
parent 3686f87377
commit 45110d05b5
3 changed files with 119 additions and 109 deletions

View File

@@ -1,14 +1,20 @@
import { mutationOptions } from "@tanstack/react-query" import { mutationOptions } from "@tanstack/react-query"
import { type } from "arktype" import { type } from "arktype"
import { accountsQuery } from "../account/api" import { Account } from "@/account/account"
import { fetchApi } from "../lib/api" import { accountsQuery } from "@/account/api"
import { currentUserQuery } from "../user/api" import { fetchApi } from "@/lib/api"
import { User } from "../user/user" import { currentUserQuery } from "@/user/api"
import { User } from "@/user/user"
const LoginResponseSchema = type({ const LoginResponseSchema = type({
user: User, user: User,
}) })
const SignUpResponse = type({
account: Account,
user: User,
})
export const loginMutation = mutationOptions({ export const loginMutation = mutationOptions({
mutationFn: async (data: { email: string; password: string }) => { mutationFn: async (data: { email: string; password: string }) => {
const [_, result] = await fetchApi("POST", "/auth/login", { const [_, result] = await fetchApi("POST", "/auth/login", {
@@ -25,3 +31,24 @@ export const loginMutation = mutationOptions({
context.client.invalidateQueries(accountsQuery) context.client.invalidateQueries(accountsQuery)
}, },
}) })
export const signUpMutation = mutationOptions({
mutationFn: async (data: {
displayName: string
email: string
password: string
}) => {
const [_, result] = await fetchApi("POST", "/accounts", {
body: JSON.stringify({
...data,
tokenDelivery: "cookie",
}),
returns: SignUpResponse,
})
return result
},
onSuccess: (data, _, __, context) => {
context.client.setQueryData(currentUserQuery.queryKey, data.user)
context.client.setQueryData(accountsQuery.queryKey, [data.account])
},
})

View File

@@ -165,7 +165,8 @@ function LoginForm() {
{isPending ? "Logging in…" : "Login"} {isPending ? "Logging in…" : "Login"}
</Button> </Button>
<FieldDescription className="text-center"> <FieldDescription className="text-center">
Don&apos;t have an account? <a href="#">Sign up</a> Don&apos;t have an account?{" "}
<a href="/sign-up">Sign up</a>
</FieldDescription> </FieldDescription>
</Field> </Field>
</FieldGroup> </FieldGroup>

View File

@@ -3,6 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { GalleryVerticalEnd } from "lucide-react" import { GalleryVerticalEnd } from "lucide-react"
import type React from "react" import type React from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { signUpMutation } from "@/auth/api"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Card, Card,
@@ -14,34 +15,21 @@ import {
import { import {
Field, Field,
FieldDescription, FieldDescription,
FieldError,
FieldGroup, FieldGroup,
FieldLabel, FieldLabel,
FieldSeparator,
} from "@/components/ui/field" } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { type AuthErrorCode, authClient, BetterAuthError } from "../auth" import { cn } from "@/lib/utils"
export const Route = createFileRoute("/sign-up")({ export const Route = createFileRoute("/sign-up")({
component: SignupPage, component: RouteComponent,
}) })
type SignUpParams = { function RouteComponent() {
displayName: string
email: string
password: string
confirmPassword: string
}
class PasswordMismatchError extends Error {
constructor() {
super("Passwords do not match")
}
}
function SignupPage() {
return ( return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-xl flex-col gap-6"> <div className="flex w-full max-w-lg flex-col gap-6">
<a <a
href="#" href="#"
className="flex items-center gap-2 self-center font-medium text-xl" className="flex items-center gap-2 self-center font-medium text-xl"
@@ -51,28 +39,31 @@ function SignupPage() {
</div> </div>
Drexa Drexa
</a> </a>
<SignUpFormContainer> <SignupFormCard />
<SignupForm />
</SignUpFormContainer>
</div> </div>
</div> </div>
) )
} }
function SignUpFormContainer({ children }: React.PropsWithChildren) { function SignupFormCard({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div className="flex flex-col gap-6"> <div className={cn("flex flex-col gap-6", className)} {...props}>
<Card> <Card>
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle className="text-xl"> <CardTitle className="text-xl">Create an account</CardTitle>
Create your account
</CardTitle>
<CardDescription> <CardDescription>
Enter your email below to create your account Enter your information below to create your account
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent>{children}</CardContent> <CardContent>
<SignupForm />
</CardContent>
</Card> </Card>
<FieldDescription className="px-6 text-center">
By clicking continue, you agree to our{" "}
<a href="#">Terms of Service</a> and{" "}
<a href="#">Privacy Policy</a>.
</FieldDescription>
</div> </div>
) )
} }
@@ -84,20 +75,7 @@ function SignupForm() {
isPending, isPending,
error: signUpError, error: signUpError,
} = useMutation({ } = useMutation({
mutationFn: async (data: SignUpParams) => { ...signUpMutation,
if (data.password !== data.confirmPassword) {
throw new PasswordMismatchError()
}
const { data: signUpData, error } = await authClient.signUp.email({
name: data.displayName,
email: data.email,
password: data.password,
})
if (error) {
throw new BetterAuthError(error.code as AuthErrorCode)
}
return signUpData
},
onSuccess: () => { onSuccess: () => {
navigate({ navigate({
to: "/", to: "/",
@@ -117,43 +95,62 @@ function SignupForm() {
displayName: formData.get("displayName") as string, displayName: formData.get("displayName") as string,
email: formData.get("email") as string, email: formData.get("email") as string,
password: formData.get("password") as string, password: formData.get("password") as string,
confirmPassword: formData.get("confirmPassword") as string,
}) })
} }
let passwordFieldError = null
let emailFieldError = null
if (signUpError instanceof BetterAuthError) {
switch (signUpError.errorCode) {
case "PASSWORD_TOO_SHORT":
passwordFieldError =
"Password must be at least 8 characters long"
break
case "INVALID_EMAIL":
emailFieldError = "Invalid email address"
break
default:
passwordFieldError = null
}
} else if (signUpError instanceof PasswordMismatchError) {
passwordFieldError = "Passwords do not match"
}
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<FieldGroup> <FieldGroup>
<Field> <Field>
<FieldLabel htmlFor="name">Your Name</FieldLabel> <Button
disabled={isPending}
variant="outline"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<title>Apple logo</title>
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
Sign up with Apple
</Button>
<Button
disabled={isPending}
variant="outline"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<title>Google logo</title>
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
Sign up with Google
</Button>
</Field>
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card">
Or continue with
</FieldSeparator>
<Field>
<FieldLabel htmlFor="name">Display Name</FieldLabel>
<Input <Input
disabled={isPending} disabled={isPending}
name="displayName" name="displayName"
type="text" type="text"
placeholder="John Doe"
required required
/> />
<FieldDescription> <FieldDescription>
This is how you will be referred to on Drexa. You can This will be displayed in the app and to other users.
change this later. Can be any name of your choice.
</FieldDescription> </FieldDescription>
</Field> </Field>
<Field> <Field>
@@ -165,12 +162,7 @@ function SignupForm() {
placeholder="m@example.com" placeholder="m@example.com"
required required
/> />
{emailFieldError ? (
<FieldError>{emailFieldError}</FieldError>
) : null}
</Field> </Field>
<Field>
<Field className="grid grid-rows-2 md:grid-rows-1 md:grid-cols-2 gap-4">
<Field> <Field>
<FieldLabel htmlFor="password">Password</FieldLabel> <FieldLabel htmlFor="password">Password</FieldLabel>
<Input <Input
@@ -179,6 +171,9 @@ function SignupForm() {
type="password" type="password"
required required
/> />
<FieldDescription>
Must be at least 8 characters long.
</FieldDescription>
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="confirm-password"> <FieldLabel htmlFor="confirm-password">
@@ -191,25 +186,12 @@ function SignupForm() {
required required
/> />
</Field> </Field>
</Field>
{passwordFieldError ? (
<FieldError>{passwordFieldError}</FieldError>
) : (
<FieldDescription>
Must be at least 8 characters long.
</FieldDescription>
)}
</Field>
<Field> <Field>
<Button <Button disabled={isPending} type="submit">
type="submit"
loading={isPending}
disabled={isPending}
>
{isPending ? "Creating account…" : "Create Account"} {isPending ? "Creating account…" : "Create Account"}
</Button> </Button>
<FieldDescription className="text-center"> <FieldDescription className="text-center">
Already have an account? <a href="/sign-in">Sign in</a> Already have an account? <a href="/login">Sign in</a>
</FieldDescription> </FieldDescription>
</Field> </Field>
</FieldGroup> </FieldGroup>