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

41
AGENTS.md Normal file
View File

@@ -0,0 +1,41 @@
# AGENTS.md — findyourpilot
Project context for AI coding agents. This file defines which skill files to load
depending on the task at hand.
<!-- intent-skills:start -->
# Skill mappings - when working in these areas, load the linked skill file into context.
skills:
- task: "Building or modifying routes, loaders, search params, or navigation with TanStack Router"
load: "node_modules/.pnpm/@tanstack+router-core@1.167.5/node_modules/@tanstack/router-core/skills/router-core/SKILL.md"
- task: "Working with route search params, type-safe URL state, or Zod-validated search params"
load: "node_modules/.pnpm/@tanstack+router-core@1.167.5/node_modules/@tanstack/router-core/skills/router-core/search-params/SKILL.md"
- task: "Implementing route guards, authentication redirects, or protected routes"
load: "node_modules/.pnpm/@tanstack+router-core@1.167.5/node_modules/@tanstack/router-core/skills/router-core/auth-and-guards/SKILL.md"
- task: "Loading data in routes, using loaders, or integrating with TanStack Query"
load: "node_modules/.pnpm/@tanstack+router-core@1.167.5/node_modules/@tanstack/router-core/skills/router-core/data-loading/SKILL.md"
- task: "Creating server functions, SSR patterns, full-stack logic, or the TanStack Start execution model"
load: "node_modules/.pnpm/@tanstack+start-client-core@1.166.13/node_modules/@tanstack/start-client-core/skills/start-core/SKILL.md"
- task: "Creating or modifying createServerFn, input validation on server functions, or handling server errors"
load: "node_modules/.pnpm/@tanstack+start-client-core@1.166.13/node_modules/@tanstack/start-client-core/skills/start-core/server-functions/SKILL.md"
- task: "Creating middleware, request middleware, or passing context between server functions"
load: "node_modules/.pnpm/@tanstack+start-client-core@1.166.13/node_modules/@tanstack/start-client-core/skills/start-core/middleware/SKILL.md"
- task: "Creating API endpoints or server-only routes (server property on createFileRoute)"
load: "node_modules/.pnpm/@tanstack+start-client-core@1.166.13/node_modules/@tanstack/start-client-core/skills/start-core/server-routes/SKILL.md"
- task: "Deploying the app to Cloudflare, Vercel, Netlify, Node.js/Docker, or configuring SSR/SPA/prerendering"
load: "node_modules/.pnpm/@tanstack+start-client-core@1.166.13/node_modules/@tanstack/start-client-core/skills/start-core/deployment/SKILL.md"
- task: "Writing, reviewing, or optimising Supabase/Postgres queries, schema design, RLS policies, or migrations"
load: ".agents/skills/supabase-postgres-best-practices/SKILL.md"
- task: "Designing or improving UI layouts, dashboards, component composition, or interactive product interfaces with HeroUI"
load: ".agents/skills/interface-design/SKILL.md"
<!-- intent-skills:end -->

View File

@@ -14,43 +14,45 @@
"machine-translate": "inlang machine translate --project project.inlang" "machine-translate": "inlang machine translate --project project.inlang"
}, },
"dependencies": { "dependencies": {
"@heroui/react": "^3.0.0-beta.8", "@heroui/react": "^3.0.0-rc.1",
"@heroui/styles": "^3.0.0-beta.8", "@heroui/styles": "^3.0.0-rc.1",
"@sentry/tanstackstart-react": "^10.42.0", "@sentry/tanstackstart-react": "^10.45.0",
"@supabase/ssr": "^0.9.0", "@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.99.1", "@supabase/supabase-js": "^2.99.3",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.91.2",
"@tanstack/react-router": "^1.166.7", "@tanstack/react-router": "^1.167.5",
"@tanstack/react-router-ssr-query": "^1.166.7", "@tanstack/react-router-ssr-query": "^1.166.9",
"@tanstack/react-start": "^1.166.8", "@tanstack/react-start": "^1.166.17",
"@tanstack/router-plugin": "^1.166.7", "@tanstack/router-plugin": "^1.166.14",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"maplibre-gl": "^5.19.0", "maplibre-gl": "^5.20.2",
"nitro": "^3.0.1-alpha.2", "nitro": "^3.0.1-alpha.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"tailwindcss": "^4.2.1", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.6", "@biomejs/biome": "^2.4.8",
"@inlang/paraglide-js": "^2.13.1", "@inlang/paraglide-js": "^2.15.0",
"@tanstack/devtools-vite": "^0.5.5", "@tanstack/devtools-vite": "^0.6.0",
"@tanstack/react-devtools": "^0.9.13", "@tanstack/react-devtools": "^0.10.0",
"@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router-devtools": "^1.166.7", "@tanstack/react-router-devtools": "^1.166.9",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^22.10.2", "@types/node": "^22.19.15",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^6.0.1",
"jsdom": "^28.1.0", "jsdom": "^29.0.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^8.0.1",
"vite-tsconfig-paths": "^6.1.1", "vite-tsconfig-paths": "^6.1.1",
"vitest": "^3.2.4" "vitest": "^4.1.0"
} }
} }

3858
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,18 +1,28 @@
import * as z from "zod" import * as z from "zod"
export const loginFormSchema = z.object({ export const loginFormSchema = z.object({
email: z.email("Invalid email address"), email: z.email("Introduce un correo válido"),
password: z.string().min(1, "Password must be at least 1 character long") password: z.string().min(1, "La contraseña es obligatoria")
}) })
export const signupFormSchema = z.object({ export const signupFormSchema = z.object({
email: z.email("Invalid email address"), email: z.email("Introduce un correo válido"),
password: z.string().min(6, "Password must be at least 6 characters long"), password: z.string().min(6, "La contraseña debe tener al menos 6 caracteres"),
name: z.string().min(1, "The field is required"), name: z.string().min(1, "El nombre es obligatorio"),
location: z.string().min(1, "The field is required"), location: z.string().min(1, "La ubicación es obligatoria"),
redirectUrl: z.string().optional() 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({ export const profileFormSchema = z.object({
id: z.uuid(), id: z.uuid(),
firstName: z.string().min(1, "First name is required"), firstName: z.string().min(1, "First name is required"),

View File

@@ -1,6 +1,5 @@
import { import {
Button, Button,
Card,
FieldError, FieldError,
Fieldset, Fieldset,
Form, Form,
@@ -30,8 +29,7 @@ function RouteComponent() {
} }
return ( return (
<div> <>
<Card.Content>
<Form onSubmit={handleFormSubmit} className="flex flex-col gap-4"> <Form onSubmit={handleFormSubmit} className="flex flex-col gap-4">
<Fieldset> <Fieldset>
<Fieldset.Group> <Fieldset.Group>
@@ -65,7 +63,7 @@ function RouteComponent() {
</TextField> </TextField>
</Fieldset.Group> </Fieldset.Group>
</Fieldset> </Fieldset>
<div className="flex justify-end">
<Button <Button
type="submit" type="submit"
className="py-6 px-8 w-full text-white text-lg" className="py-6 px-8 w-full text-white text-lg"
@@ -75,17 +73,15 @@ function RouteComponent() {
{isPending ? <Spinner /> : <LogIn size={18} />} {isPending ? <Spinner /> : <LogIn size={18} />}
Entrar Entrar
</Button> </Button>
</div>
</Form> </Form>
</Card.Content> <div className="flex justify-evenly w-full gap-4 mt-2">
<Card.Footer>
<div className="flex justify-evenly w-full gap-4">
<Button size="lg" className="w-full" variant="secondary"> <Button size="lg" className="w-full" variant="secondary">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
height="16" height="16"
viewBox="0 0 16 16" viewBox="0 0 16 16"
aria-hidden="true"
> >
<g fill="none" fillRule="evenodd" clipRule="evenodd"> <g fill="none" fillRule="evenodd" clipRule="evenodd">
<path <path
@@ -115,9 +111,10 @@ function RouteComponent() {
<Button size="lg" className="w-full" variant="secondary"> <Button size="lg" className="w-full" variant="secondary">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="256" width="16"
height="256" height="16"
viewBox="0 0 256 256" viewBox="0 0 256 256"
aria-hidden="true"
> >
<path <path
fill="#1877f2" fill="#1877f2"
@@ -131,7 +128,6 @@ function RouteComponent() {
Facebook Facebook
</Button> </Button>
</div> </div>
</Card.Footer> </>
</div>
) )
} }

View File

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