From 9df0aaf55c4ac331c89f9a5e9d26fa5c93daa687 Mon Sep 17 00:00:00 2001 From: Chaithra S Date: Sun, 6 Sep 2026 14:34:41 +0530 Subject: [PATCH 1/3] Add form validation, providers, and onboarding hooks --- apps/web/app/(auth)/login/page.test.tsx | 27 ++- apps/web/app/(auth)/login/page.tsx | 125 +++++----- apps/web/app/(auth)/role-select/page.test.tsx | 15 +- apps/web/app/(auth)/role-select/page.tsx | 7 +- apps/web/app/(onboarding)/recruiter/page.tsx | 140 +++++++----- apps/web/app/(onboarding)/student/page.tsx | 216 ++++++++++-------- apps/web/app/layout.tsx | 3 +- apps/web/app/providers.tsx | 27 +++ apps/web/components/ui/form-field.tsx | 42 ++++ apps/web/lib/env.ts | 28 +++ apps/web/lib/hooks/use-auth.ts | 52 +++++ apps/web/lib/hooks/use-onboarding.ts | 71 ++++++ apps/web/lib/schemas/auth.schema.ts | 8 + .../schemas/recruiter-onboarding.schema.ts | 20 ++ .../lib/schemas/student-onboarding.schema.ts | 47 ++++ apps/web/package.json | 7 +- pnpm-lock.yaml | 71 +++--- 17 files changed, 650 insertions(+), 256 deletions(-) create mode 100644 apps/web/app/providers.tsx create mode 100644 apps/web/components/ui/form-field.tsx create mode 100644 apps/web/lib/env.ts create mode 100644 apps/web/lib/hooks/use-auth.ts create mode 100644 apps/web/lib/hooks/use-onboarding.ts create mode 100644 apps/web/lib/schemas/auth.schema.ts create mode 100644 apps/web/lib/schemas/recruiter-onboarding.schema.ts create mode 100644 apps/web/lib/schemas/student-onboarding.schema.ts diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx index ffac55e..fcc8fa3 100644 --- a/apps/web/app/(auth)/login/page.test.tsx +++ b/apps/web/app/(auth)/login/page.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Providers } from '@/app/providers' import LoginPage from './page' // ========================================== @@ -17,6 +18,10 @@ vi.mock('./CharactersScene', () => ({ CharactersScene: () =>
, })) +function renderWithProviders(ui: React.ReactElement) { + return render({ui}) +} + // ========================================== // TESTS // ========================================== @@ -27,14 +32,14 @@ describe('LoginPage', () => { }) it('renders the Google and email options', () => { - render() + renderWithProviders() expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument() expect(screen.getByLabelText(/email/i)).toBeInTheDocument() }) it('starts the Google handshake and returns to role selection', async () => { - render() + renderWithProviders() fireEvent.click(screen.getByRole('button', { name: /continue with google/i })) @@ -47,7 +52,7 @@ describe('LoginPage', () => { }) it('shows a redirect notice while the handshake starts', async () => { - render() + renderWithProviders() fireEvent.click(screen.getByRole('button', { name: /continue with google/i })) @@ -57,18 +62,30 @@ describe('LoginPage', () => { it('surfaces a failure to start the handshake', async () => { socialMock.mockResolvedValue({ error: { message: 'Provider unavailable' } }) - render() + renderWithProviders() fireEvent.click(screen.getByRole('button', { name: /continue with google/i })) expect(await screen.findByRole('alert')).toHaveTextContent('Provider unavailable') }) it('does not fake a session when the email form is submitted', async () => { - render() + renderWithProviders() + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'user@example.com' } }) fireEvent.click(screen.getByRole('button', { name: /continue with email/i })) expect(await screen.findByRole('alert')).toHaveTextContent(/isn't available yet/i) expect(socialMock).not.toHaveBeenCalled() }) + + it('validates email format with Zod schema', async () => { + renderWithProviders() + + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'invalid-email' } }) + fireEvent.click(screen.getByRole('button', { name: /continue with email/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent( + /please enter a valid email address/i, + ) + }) }) diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 9d6d4ea..de7f1b7 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,8 +1,13 @@ 'use client' +import { zodResolver } from '@hookform/resolvers/zod' import { ArrowRight, Loader2 } from 'lucide-react' -import { useState, type ChangeEvent, type FormEvent } from 'react' -import { authClient } from '@/lib/auth/client' +import { useState } from 'react' +import { useForm } from 'react-hook-form' + +import { FormField } from '@/components/ui/form-field' +import { useSocialSignIn } from '@/lib/hooks/use-auth' +import { loginSchema, type LoginInput } from '@/lib/schemas/auth.schema' import { CharactersScene, type CharacterSceneState } from './CharactersScene' import { sound } from './audio' @@ -42,18 +47,22 @@ export default function LoginPage() { }) const [isSignUp, setIsSignUp] = useState(false) - const [name, setName] = useState('') - const [email, setEmail] = useState('') const [isRedirecting, setIsRedirecting] = useState(false) const [errorMsg, setErrorMsg] = useState('') - const handleEmailChange = (e: ChangeEvent) => { - const val = e.target.value - setEmail(val) - setState((s) => ({ ...s, emailLength: val.length })) - sound.playKeystroke(val.length) - setErrorMsg('') - } + const socialSignInMutation = useSocialSignIn() + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + name: '', + email: '', + }, + }) const failWith = (message: string) => { setIsRedirecting(false) @@ -62,12 +71,7 @@ export default function LoginPage() { sound.playError() } - /** - * Email sign-in is not wired up yet — Google is the only provider. The fields - * stay in place for a later change, but submitting must never fake a session. - */ - const handleSubmit = (e: FormEvent) => { - e.preventDefault() + const onSubmit = () => { failWith("Email sign-in isn't available yet — continue with Google.") } @@ -77,15 +81,15 @@ export default function LoginPage() { setState((s) => ({ ...s, isError: false })) sound.playPop(520) - const { error } = await authClient.signIn.social({ - provider: 'google', - callbackURL: '/role-select', - }) - - // On success the browser navigates to Google, so this only runs when the - // handshake could not be started at all. - if (error) { - failWith(error.message ?? 'Could not start Google sign-in. Please try again.') + try { + await socialSignInMutation.mutateAsync({ + provider: 'google', + callbackURL: '/role-select', + }) + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Could not start Google sign-in. Please try again.' + failWith(message) } } @@ -141,59 +145,50 @@ export default function LoginPage() {
{/* Form */} -
+ {isSignUp && ( -
- + setName(e.target.value)} + {...register('name')} onFocus={() => { setState((s) => ({ ...s, focusedField: 'email' })) sound.playPop(380) }} onBlur={() => setState((s) => ({ ...s, focusedField: 'none' }))} placeholder="Your full name" - className="w-full px-3.5 py-2.5 rounded-xl border border-zinc-200 text-sm focus:outline-none focus:ring-2 focus:ring-zinc-900/10 focus:border-zinc-900 transition bg-zinc-50/50 hover:bg-zinc-50" + className="w-full px-3.5 py-2.5 rounded-xl border border-zinc-200 text-sm focus:outline-none focus:ring-2 focus:ring-zinc-900/10 focus:border-zinc-900 transition bg-zinc-50/50 hover:bg-zinc-50 text-zinc-900" /> -
+ )} {/* Email Field */} -
- -
- { - setState((s) => ({ ...s, focusedField: 'email' })) - sound.playPop(420) - }} - onBlur={() => setState((s) => ({ ...s, focusedField: 'none' }))} - placeholder="Enter your email" - className={`w-full px-3.5 py-2.5 rounded-xl border text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none transition bg-zinc-50/40 hover:bg-zinc-50 ${ - state.focusedField === 'email' - ? 'border-zinc-900 ring-2 ring-zinc-900/10 bg-white' - : 'border-zinc-200' - }`} - /> -
-
+ + { + const val = e.target.value + setState((s) => ({ ...s, emailLength: val.length })) + sound.playKeystroke(val.length) + setErrorMsg('') + }, + })} + onFocus={() => { + setState((s) => ({ ...s, focusedField: 'email' })) + sound.playPop(420) + }} + onBlur={() => setState((s) => ({ ...s, focusedField: 'none' }))} + placeholder="Enter your email" + className={`w-full px-3.5 py-2.5 rounded-xl border text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none transition bg-zinc-50/40 hover:bg-zinc-50 ${ + state.focusedField === 'email' + ? 'border-zinc-900 ring-2 ring-zinc-900/10 bg-white' + : 'border-zinc-200' + }`} + /> + {/* Error banner */} {errorMsg && ( diff --git a/apps/web/app/(auth)/role-select/page.test.tsx b/apps/web/app/(auth)/role-select/page.test.tsx index 63694bf..3099856 100644 --- a/apps/web/app/(auth)/role-select/page.test.tsx +++ b/apps/web/app/(auth)/role-select/page.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ApiError } from '@/lib/api-client' +import { Providers } from '@/app/providers' import RoleSelectPage from './page' // ========================================== @@ -23,6 +24,10 @@ vi.mock('@/lib/api-client', async (importOriginal) => { return { ...actual, apiFetch: apiFetchMock } }) +function renderWithProviders(ui: React.ReactElement) { + return render({ui}) +} + // ========================================== // HELPERS // ========================================== @@ -42,7 +47,7 @@ describe('RoleSelectPage', () => { }) it('persists the student role before navigating', async () => { - render() + renderWithProviders() clickRole('student') @@ -56,7 +61,7 @@ describe('RoleSelectPage', () => { }) it('persists the recruiter role before navigating', async () => { - render() + renderWithProviders() clickRole('recruiter') @@ -72,7 +77,7 @@ describe('RoleSelectPage', () => { it('sends an expired session back to sign in', async () => { apiFetchMock.mockRejectedValue(new ApiError(401, 'Your session has expired.')) - render() + renderWithProviders() clickRole('student') await waitFor(() => expect(pushMock).toHaveBeenCalledWith('/login')) @@ -82,7 +87,7 @@ describe('RoleSelectPage', () => { it('reports a failed save and stays put', async () => { apiFetchMock.mockRejectedValue(new ApiError(500, 'Something went wrong')) - render() + renderWithProviders() clickRole('student') expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong') @@ -92,7 +97,7 @@ describe('RoleSelectPage', () => { it('reports a network failure in plain language', async () => { apiFetchMock.mockRejectedValue(new TypeError('Failed to fetch')) - render() + renderWithProviders() clickRole('student') expect(await screen.findByRole('alert')).toHaveTextContent(/check your connection/i) diff --git a/apps/web/app/(auth)/role-select/page.tsx b/apps/web/app/(auth)/role-select/page.tsx index 2982454..40c9605 100644 --- a/apps/web/app/(auth)/role-select/page.tsx +++ b/apps/web/app/(auth)/role-select/page.tsx @@ -4,7 +4,8 @@ import { motion } from 'motion/react' import { ArrowRight, Check } from 'lucide-react' import { useRouter } from 'next/navigation' import { useEffect, useRef, useState, type MouseEvent } from 'react' -import { ApiError, apiFetch } from '@/lib/api-client' +import { ApiError } from '@/lib/api-client' +import { useSetUserRole } from '@/lib/hooks/use-auth' export default function RoleSelectPage() { const router = useRouter() @@ -37,6 +38,8 @@ export default function RoleSelectPage() { return () => clearInterval(interval) }, []) + const setRoleMutation = useSetUserRole() + /** * Persists the chosen role, then moves on to the matching dashboard. * @@ -51,7 +54,7 @@ export default function RoleSelectPage() { setErrorMsg('') try { - await apiFetch('/api/users/me/role', { method: 'PATCH', body: { role } }) + await setRoleMutation.mutateAsync(role) } catch (error) { // An expired session cannot be recovered here — send them back to sign in. if (error instanceof ApiError && error.status === 401) { diff --git a/apps/web/app/(onboarding)/recruiter/page.tsx b/apps/web/app/(onboarding)/recruiter/page.tsx index 90912ce..d770535 100644 --- a/apps/web/app/(onboarding)/recruiter/page.tsx +++ b/apps/web/app/(onboarding)/recruiter/page.tsx @@ -1,10 +1,18 @@ 'use client' +import { zodResolver } from '@hookform/resolvers/zod' import { ArrowLeft, ArrowRight, Building2, Globe, Mail, MapPin, Sparkles, X } from 'lucide-react' import { useRouter } from 'next/navigation' import { useState } from 'react' +import { useForm } from 'react-hook-form' import confetti from 'canvas-confetti' +import { useSaveRecruiterProfile } from '@/lib/hooks/use-onboarding' +import { + recruiterOnboardingSchema, + type RecruiterOnboardingInput, +} from '@/lib/schemas/recruiter-onboarding.schema' + export interface RecruiterProfileData { companyName: string companyMail: string @@ -14,54 +22,50 @@ export interface RecruiterProfileData { export default function RecruiterOnboardingPage() { const router = useRouter() - - const [companyName, setCompanyName] = useState('') - const [companyMail, setCompanyMail] = useState('') - const [companyUrl, setCompanyUrl] = useState('') - const [headquartersLocation, setHeadquartersLocation] = useState('') const [errorMsg, setErrorMsg] = useState('') const [isCompleted, setIsCompleted] = useState(false) - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - - if (!companyName.trim()) { - setErrorMsg('Please enter your company name.') - return - } - - if (!companyMail.trim()) { - setErrorMsg('Please enter your company work email.') - return - } - + const saveRecruiterMutation = useSaveRecruiterProfile() + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(recruiterOnboardingSchema), + defaultValues: { + companyName: '', + companyMail: '', + companyUrl: '', + headquartersLocation: '', + }, + }) + + const onSubmit = async (data: RecruiterOnboardingInput) => { setErrorMsg('') - - const recruiterProfile: RecruiterProfileData = { - companyName: companyName.trim(), - companyMail: companyMail.trim(), - companyUrl: companyUrl.trim(), - headquartersLocation: headquartersLocation.trim(), - } - - localStorage.setItem('recruiter_profile', JSON.stringify(recruiterProfile)) - localStorage.setItem('user_role', 'recruiter') - setIsCompleted(true) - try { - confetti({ - particleCount: 90, - spread: 75, - origin: { y: 0.6 }, - colors: ['#00C26D', '#34D399', '#10B981', '#059669', '#3B82F6'], - }) - } catch { - // Confetti fallback + await saveRecruiterMutation.mutateAsync(data) + setIsCompleted(true) + + try { + confetti({ + particleCount: 90, + spread: 75, + origin: { y: 0.6 }, + colors: ['#00C26D', '#34D399', '#10B981', '#059669', '#3B82F6'], + }) + } catch { + // Confetti fallback + } + + setTimeout(() => { + router.push('/landing') + }, 1400) + } catch (err: unknown) { + if (err instanceof Error) { + setErrorMsg(err.message) + } } - - setTimeout(() => { - router.push('/landing') - }, 1400) } const handleBack = () => { @@ -207,7 +211,7 @@ export default function RecruiterOnboardingPage() { - + @@ -216,7 +220,7 @@ export default function RecruiterOnboardingPage() { - + @@ -304,7 +308,8 @@ export default function RecruiterOnboardingPage() { {/* 4 Form Fields Form */} {/* Field 1: Company Name */} @@ -316,13 +321,16 @@ export default function RecruiterOnboardingPage() { setCompanyName(e.target.value)} + {...register('companyName')} placeholder="e.g. Acme Innovations Inc." className="w-full pl-10 pr-3.5 py-2.5 rounded-xl border border-border-subtle focus:border-brand focus:ring-2 focus:ring-brand/15 outline-none text-xs sm:text-sm transition bg-card placeholder:text-slate-400" /> + {errors.companyName && ( +

+ {errors.companyName.message} +

+ )} {/* Field 2: Company Work Email */} @@ -334,16 +342,20 @@ export default function RecruiterOnboardingPage() { setCompanyMail(e.target.value)} + {...register('companyMail')} placeholder="recruiting@company.com" className="w-full pl-10 pr-3.5 py-2.5 rounded-xl border border-border-subtle focus:border-brand focus:ring-2 focus:ring-brand/15 outline-none text-xs sm:text-sm transition bg-card placeholder:text-slate-400" /> -

- Official corporate email for candidate correspondence and verification. -

+ {errors.companyMail ? ( +

+ {errors.companyMail.message} +

+ ) : ( +

+ Official corporate email for candidate correspondence and verification. +

+ )} {/* Field 3: Company Website URL */} @@ -355,12 +367,16 @@ export default function RecruiterOnboardingPage() { setCompanyUrl(e.target.value)} + {...register('companyUrl')} placeholder="https://acme.example.com" className="w-full pl-10 pr-3.5 py-2.5 rounded-xl border border-border-subtle focus:border-brand focus:ring-2 focus:ring-brand/15 outline-none text-xs sm:text-sm transition bg-card placeholder:text-slate-400" /> + {errors.companyUrl && ( +

+ {errors.companyUrl.message} +

+ )} {/* Field 4: Headquarters Location */} @@ -372,12 +388,16 @@ export default function RecruiterOnboardingPage() { setHeadquartersLocation(e.target.value)} + {...register('headquartersLocation')} placeholder="e.g. Bengaluru, India or San Francisco, CA" className="w-full pl-10 pr-3.5 py-2.5 rounded-xl border border-border-subtle focus:border-brand focus:ring-2 focus:ring-brand/15 outline-none text-xs sm:text-sm transition bg-card placeholder:text-slate-400" /> + {errors.headquartersLocation && ( +

+ {errors.headquartersLocation.message} +

+ )} {/* Bottom Actions Bar */} @@ -395,8 +415,8 @@ export default function RecruiterOnboardingPage() { {/* Complete Setup Button */}