feat: Add client-side password confirmation to signup, localize validation messages, and refactor login form layout.

This commit is contained in:
Jrodenas
2026-03-21 23:13:20 +01:00
parent 08d0a5a099
commit cf8c592c78
7 changed files with 2174 additions and 2184 deletions

View File

@@ -1,38 +1,44 @@
import { toast } from "@heroui/react"
import { useMutation } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import type z from "zod"
import { user } from "@/lib/server/user"
import { signupFormSchema } from "@/lib/validation/user"
type TSignupForm = z.infer<typeof signupFormSchema>
export const useSignup = () => {
const navigate = useNavigate()
const signup = useMutation({
mutationKey: ["signup"],
mutationFn: async (data: TSignupForm) => user.signup({ data }),
onSuccess: () => {
navigate({
to: "/access/login"
})
}
})
const validateSignup = (formData: TSignupForm) => {
if (!signupFormSchema.safeParse(formData).success) {
toast.danger("Signup failed. Please check your input.")
}
const promise = signup.mutateAsync(formData)
toast.promise(promise, {
loading: "Signing up...",
success: "Signup successful! Redirecting to login...",
error: "Signup failed!"
})
}
return {
signup: validateSignup,
isPending: signup.isPending
}
}
import { toast } from "@heroui/react"
import { useMutation } from "@tanstack/react-query"
import type z from "zod"
import { user } from "@/lib/server/user"
import type { signupClientFormSchema } from "@/lib/validation/user"
type TSignupClientForm = z.infer<typeof signupClientFormSchema>
export const useSignup = () => {
const signupMutation = useMutation({
mutationKey: ["signup"],
mutationFn: async (data: TSignupClientForm) => {
const { confirmPassword: _, ...serverData } = data
const response = await user.signup({ data: serverData })
if (response && "error" in response && response.error) {
throw new Error(
"message" in response
? (response.message as string)
: "Error desconocido"
)
}
}
})
const validateSignup = (formData: TSignupClientForm) => {
if (formData.password !== formData.confirmPassword) {
toast.danger("Las contraseñas no coinciden")
return
}
const promise = signupMutation.mutateAsync(formData)
toast.promise(promise, {
loading: "Creando tu cuenta...",
success: "¡Cuenta creada! Revisa tu correo para confirmarla.",
error: (error: Error) => error.message
})
}
return {
signup: validateSignup,
isPending: signupMutation.isPending
}
}

View File

@@ -1,18 +1,28 @@
import * as z from "zod"
export const loginFormSchema = z.object({
email: z.email("Invalid email address"),
password: z.string().min(1, "Password must be at least 1 character long")
email: z.email("Introduce un correo válido"),
password: z.string().min(1, "La contraseña es obligatoria")
})
export const signupFormSchema = z.object({
email: z.email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters long"),
name: z.string().min(1, "The field is required"),
location: z.string().min(1, "The field is required"),
email: z.email("Introduce un correo válido"),
password: z.string().min(6, "La contraseña debe tener al menos 6 caracteres"),
name: z.string().min(1, "El nombre es obligatorio"),
location: z.string().min(1, "La ubicación es obligatoria"),
redirectUrl: z.string().optional()
})
// Schema extendido para el formulario cliente (incluye confirmación de contraseña)
export const signupClientFormSchema = signupFormSchema
.extend({
confirmPassword: z.string().min(6, "Confirma tu contraseña")
})
.refine((data) => data.password === data.confirmPassword, {
message: "Las contraseñas no coinciden",
path: ["confirmPassword"]
})
export const profileFormSchema = z.object({
id: z.uuid(),
firstName: z.string().min(1, "First name is required"),

View File

@@ -1,6 +1,5 @@
import {
Button,
Card,
FieldError,
Fieldset,
Form,
@@ -30,108 +29,105 @@ function RouteComponent() {
}
return (
<div>
<Card.Content>
<Form onSubmit={handleFormSubmit} className="flex flex-col gap-4">
<Fieldset>
<Fieldset.Group>
<TextField
type="email"
name="email"
variant="secondary"
className="py-4 text-lg"
isRequired
defaultValue={import.meta.env.VITE_LOGIN_USER}
>
<Label isRequired className="ml-4 text-lg">
Correo
</Label>
<Input placeholder="Introduce tu correo" />
<FieldError />
</TextField>
<TextField
type="password"
name="password"
variant="secondary"
className="py-4 text-lg"
isRequired
defaultValue={import.meta.env.VITE_PASSWORD_USER}
>
<Label isRequired className="ml-4 text-lg">
Contraseña
</Label>
<Input placeholder="Introduce tu contraseña" />
<FieldError />
</TextField>
</Fieldset.Group>
</Fieldset>
<div className="flex justify-end">
<Button
type="submit"
className="py-6 px-8 w-full text-white text-lg"
size="lg"
isPending={isPending}
<>
<Form onSubmit={handleFormSubmit} className="flex flex-col gap-4">
<Fieldset>
<Fieldset.Group>
<TextField
type="email"
name="email"
variant="secondary"
className="py-4 text-lg"
isRequired
defaultValue={import.meta.env.VITE_LOGIN_USER}
>
{isPending ? <Spinner /> : <LogIn size={18} />}
Entrar
</Button>
</div>
</Form>
</Card.Content>
<Card.Footer>
<div className="flex justify-evenly w-full gap-4">
<Button size="lg" className="w-full" variant="secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
>
<g fill="none" fillRule="evenodd" clipRule="evenodd">
<path
fill="#f44336"
d="M7.209 1.061c.725-.081 1.154-.081 1.933 0a6.57 6.57 0 0 1 3.65 1.82a100 100 0 0 0-1.986 1.93q-1.876-1.59-4.188-.734q-1.696.78-2.362 2.528a78 78 0 0 1-2.148-1.658a.26.26 0 0 0-.16-.027q1.683-3.245 5.26-3.86"
opacity="0.987"
/>
<path
fill="#ffc107"
d="M1.946 4.92q.085-.013.161.027a78 78 0 0 0 2.148 1.658A7.6 7.6 0 0 0 4.04 7.99q.037.678.215 1.331L2 11.116Q.527 8.038 1.946 4.92"
opacity="0.997"
/>
<path
fill="#448aff"
d="M12.685 13.29a26 26 0 0 0-2.202-1.74q1.15-.812 1.396-2.228H8.122V6.713q3.25-.027 6.497.055q.616 3.345-1.423 6.032a7 7 0 0 1-.51.49"
opacity="0.999"
/>
<path
fill="#43a047"
d="M4.255 9.322q1.23 3.057 4.51 2.854a3.94 3.94 0 0 0 1.718-.626q1.148.812 2.202 1.74a6.62 6.62 0 0 1-4.027 1.684a6.4 6.4 0 0 1-1.02 0Q3.82 14.524 2 11.116z"
opacity="0.993"
/>
</g>
</svg>
Google
</Button>
<Button size="lg" className="w-full" variant="secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="256"
height="256"
viewBox="0 0 256 256"
<Label isRequired className="ml-4 text-lg">
Correo
</Label>
<Input placeholder="Introduce tu correo" />
<FieldError />
</TextField>
<TextField
type="password"
name="password"
variant="secondary"
className="py-4 text-lg"
isRequired
defaultValue={import.meta.env.VITE_PASSWORD_USER}
>
<Label isRequired className="ml-4 text-lg">
Contraseña
</Label>
<Input placeholder="Introduce tu contraseña" />
<FieldError />
</TextField>
</Fieldset.Group>
</Fieldset>
<Button
type="submit"
className="py-6 px-8 w-full text-white text-lg"
size="lg"
isPending={isPending}
>
{isPending ? <Spinner /> : <LogIn size={18} />}
Entrar
</Button>
</Form>
<div className="flex justify-evenly w-full gap-4 mt-2">
<Button size="lg" className="w-full" variant="secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
aria-hidden="true"
>
<g fill="none" fillRule="evenodd" clipRule="evenodd">
<path
fill="#1877f2"
d="M256 128C256 57.308 198.692 0 128 0S0 57.308 0 128c0 63.888 46.808 116.843 108 126.445V165H75.5v-37H108V99.8c0-32.08 19.11-49.8 48.348-49.8C170.352 50 185 52.5 185 52.5V84h-16.14C152.959 84 148 93.867 148 103.99V128h35.5l-5.675 37H148v89.445c61.192-9.602 108-62.556 108-126.445"
fill="#f44336"
d="M7.209 1.061c.725-.081 1.154-.081 1.933 0a6.57 6.57 0 0 1 3.65 1.82a100 100 0 0 0-1.986 1.93q-1.876-1.59-4.188-.734q-1.696.78-2.362 2.528a78 78 0 0 1-2.148-1.658a.26.26 0 0 0-.16-.027q1.683-3.245 5.26-3.86"
opacity="0.987"
/>
<path
fill="#fff"
d="m177.825 165l5.675-37H148v-24.01C148 93.866 152.959 84 168.86 84H185V52.5S170.352 50 156.347 50C127.11 50 108 67.72 108 99.8V128H75.5v37H108v89.445A129 129 0 0 0 128 256a129 129 0 0 0 20-1.555V165z"
fill="#ffc107"
d="M1.946 4.92q.085-.013.161.027a78 78 0 0 0 2.148 1.658A7.6 7.6 0 0 0 4.04 7.99q.037.678.215 1.331L2 11.116Q.527 8.038 1.946 4.92"
opacity="0.997"
/>
</svg>
Facebook
</Button>
</div>
</Card.Footer>
</div>
<path
fill="#448aff"
d="M12.685 13.29a26 26 0 0 0-2.202-1.74q1.15-.812 1.396-2.228H8.122V6.713q3.25-.027 6.497.055q.616 3.345-1.423 6.032a7 7 0 0 1-.51.49"
opacity="0.999"
/>
<path
fill="#43a047"
d="M4.255 9.322q1.23 3.057 4.51 2.854a3.94 3.94 0 0 0 1.718-.626q1.148.812 2.202 1.74a6.62 6.62 0 0 1-4.027 1.684a6.4 6.4 0 0 1-1.02 0Q3.82 14.524 2 11.116z"
opacity="0.993"
/>
</g>
</svg>
Google
</Button>
<Button size="lg" className="w-full" variant="secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 256 256"
aria-hidden="true"
>
<path
fill="#1877f2"
d="M256 128C256 57.308 198.692 0 128 0S0 57.308 0 128c0 63.888 46.808 116.843 108 126.445V165H75.5v-37H108V99.8c0-32.08 19.11-49.8 48.348-49.8C170.352 50 185 52.5 185 52.5V84h-16.14C152.959 84 148 93.867 148 103.99V128h35.5l-5.675 37H148v89.445c61.192-9.602 108-62.556 108-126.445"
/>
<path
fill="#fff"
d="m177.825 165l5.675-37H148v-24.01C148 93.866 152.959 84 168.86 84H185V52.5S170.352 50 156.347 50C127.11 50 108 67.72 108 99.8V128H75.5v37H108v89.445A129 129 0 0 0 128 256a129 129 0 0 0 20-1.555V165z"
/>
</svg>
Facebook
</Button>
</div>
</>
)
}

View File

@@ -9,7 +9,7 @@ import {
TextField
} from "@heroui/react"
import { createFileRoute } from "@tanstack/react-router"
import { LogIn } from "lucide-react"
import { UserPlus } from "lucide-react"
import { useSignup } from "@/lib/hooks/useSignup"
export const Route = createFileRoute("/access/register")({
@@ -22,67 +22,105 @@ function RouteComponent() {
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const email = formData.get("email") as string
const password = formData.get("password") as string
const location = formData.get("location") as string
const name = formData.get("name") as string
signup({
email,
password,
location,
name
email: formData.get("email") as string,
password: formData.get("password") as string,
confirmPassword: formData.get("confirmPassword") as string,
location: formData.get("location") as string,
name: formData.get("name") as string
})
}
return (
<div>
<>
<Form onSubmit={handleFormSubmit} className="flex flex-col gap-4">
<Fieldset>
<Fieldset.Group>
<TextField
type="text"
name="name"
variant="secondary"
className="py-2 text-lg"
isRequired
>
<Label isRequired className="ml-4 text-lg">
Nombre completo
</Label>
<Input placeholder="Tu nombre y apellidos" />
<FieldError />
</TextField>
<TextField
type="email"
name="email"
variant="secondary"
className="py-4 text-lg"
className="py-2 text-lg"
isRequired
>
<Label>Correo</Label>
<Input placeholder="Introduce tu correo" />
<Label isRequired className="ml-4 text-lg">
Correo electrónico
</Label>
<Input placeholder="tu@correo.com" />
<FieldError />
</TextField>
<TextField
type="text"
name="location"
variant="secondary"
className="py-2 text-lg"
isRequired
>
<Label isRequired className="ml-4 text-lg">
Ubicación
</Label>
<Input placeholder="Ciudad, País" />
<FieldError />
</TextField>
<TextField
type="password"
name="password"
variant="secondary"
className="py-2 text-lg"
isRequired
>
<Label isRequired className="ml-4 text-lg">
Contraseña
</Label>
<Input placeholder="Mínimo 6 caracteres" />
<FieldError />
</TextField>
<TextField
type="password"
name="confirmPassword"
variant="secondary"
className="py-2 text-lg"
isRequired
>
<Label isRequired className="ml-4 text-lg">
Confirmar contraseña
</Label>
<Input placeholder="Repite tu contraseña" />
<FieldError />
</TextField>
</Fieldset.Group>
</Fieldset>
<TextField
type="password"
name="password"
variant="secondary"
className="py-4 text-lg"
isRequired
<Button
type="submit"
className="py-6 px-8 w-full text-white text-lg"
size="lg"
isPending={isPending}
>
<Label>Contraseña</Label>
<Input placeholder="Introduce tu contraseña" />
<FieldError />
</TextField>
<div className="flex justify-end">
<Button
type="submit"
className="py-6 px-8 w-full text-white text-lg"
size="lg"
isPending={isPending}
>
{isPending ? <Spinner /> : <LogIn size={18} />}
Entrar
</Button>
</div>
{isPending ? <Spinner /> : <UserPlus size={18} />}
Crear cuenta
</Button>
</Form>
<div className="flex justify-evenly w-full gap-4">
<div className="flex justify-evenly w-full gap-4 mt-2">
<Button size="lg" className="w-full" variant="secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
aria-hidden="true"
>
<g fill="none" fillRule="evenodd" clipRule="evenodd">
<path
@@ -112,9 +150,10 @@ function RouteComponent() {
<Button size="lg" className="w-full" variant="secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="256"
height="256"
width="16"
height="16"
viewBox="0 0 256 256"
aria-hidden="true"
>
<path
fill="#1877f2"
@@ -128,6 +167,6 @@ function RouteComponent() {
Facebook
</Button>
</div>
</div>
</>
)
}