diff --git a/apps/web/app/(auth)/login/CharactersScene.tsx b/apps/web/app/(auth)/login/CharactersScene.tsx index ea167ec..a081452 100644 --- a/apps/web/app/(auth)/login/CharactersScene.tsx +++ b/apps/web/app/(auth)/login/CharactersScene.tsx @@ -2,7 +2,6 @@ import { motion } from 'motion/react' import { useEffect, useRef, useState, type MouseEvent } from 'react' -import { sound } from './audio' export interface CharacterSceneState { focusedField: 'none' | 'email' | 'submit' | 'google' @@ -93,27 +92,6 @@ export function CharactersScene({ state, onCharacterPoke }: CharactersSceneProps const handlePoke = (characterName: string) => { setPokeId(characterName) - switch (characterName) { - case 'red': - sound.playBoing() - break - case 'pink': - sound.playBlush() - break - case 'cyan': - sound.playFloat() - break - case 'blue': - sound.playGiggle() - break - case 'yellow': - sound.playSpin() - break - default: - sound.playPop(440) - break - } - if (onCharacterPoke) onCharacterPoke() setTimeout(() => { setPokeId(null) diff --git a/apps/web/app/(auth)/login/audio.ts b/apps/web/app/(auth)/login/audio.ts index 6df1159..8799b53 100644 --- a/apps/web/app/(auth)/login/audio.ts +++ b/apps/web/app/(auth)/login/audio.ts @@ -2,7 +2,7 @@ class SoundEffects { private ctx: AudioContext | null = null - public enabled = true + public enabled = false private initCtx() { if (!this.ctx && typeof window !== 'undefined') { 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..c787231 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,10 +1,16 @@ '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 { Input } from '@/components/ui/input' +import { useSocialSignIn } from '@/lib/hooks/use-auth' +import { cn } from '@/lib/utils' +import { loginSchema, type LoginInput } from '@/lib/schemas/auth.schema' import { CharactersScene, type CharacterSceneState } from './CharactersScene' -import { sound } from './audio' function GoogleIcon({ className = 'w-4 h-4' }: { className?: string }) { return ( @@ -29,6 +35,11 @@ function GoogleIcon({ className = 'w-4 h-4' }: { className?: string }) { ) } +const DEFAULT_VALUES: LoginInput = { + name: '', + email: '', +} + export default function LoginPage() { const [state, setState] = useState({ focusedField: 'none', @@ -42,32 +53,27 @@ 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: DEFAULT_VALUES, + }) const failWith = (message: string) => { setIsRedirecting(false) setErrorMsg(message) setState((s) => ({ ...s, isError: true })) - 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.") } @@ -75,17 +81,16 @@ export default function LoginPage() { setIsRedirecting(true) setErrorMsg('') 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 +146,47 @@ 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 })) + setErrorMsg('') + }, + })} + onFocus={() => { + setState((s) => ({ ...s, focusedField: 'email' })) + }} + onBlur={() => setState((s) => ({ ...s, focusedField: 'none' }))} + placeholder="Enter your email" + className={cn( + 'w-full px-3.5 py-2.5 rounded-xl border border-zinc-200 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', + )} + /> + {/* Error banner */} {errorMsg && ( @@ -246,7 +239,6 @@ export default function LoginPage() { id="toggle-to-login-btn" onClick={() => { setIsSignUp(false) - sound.playPop(400) }} className="font-semibold text-zinc-900 hover:underline cursor-pointer ml-1" > @@ -261,7 +253,6 @@ export default function LoginPage() { id="toggle-to-signup-btn" onClick={() => { setIsSignUp(true) - sound.playPop(400) }} className="font-semibold text-zinc-900 hover:underline cursor-pointer ml-1" > 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..13c67e4 100644 --- a/apps/web/app/(auth)/role-select/page.tsx +++ b/apps/web/app/(auth)/role-select/page.tsx @@ -4,12 +4,14 @@ 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' +import { type SelfAssignableRole } from '@/lib/schemas/auth.schema' export default function RoleSelectPage() { const router = useRouter() - const [selectedRole, setSelectedRole] = useState<'student' | 'recruiter' | null>(null) - const [hoveredRole, setHoveredRole] = useState<'student' | 'recruiter' | null>(null) + const [selectedRole, setSelectedRole] = useState(null) + const [hoveredRole, setHoveredRole] = useState(null) const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) const [blink, setBlink] = useState(false) const [isSaving, setIsSaving] = useState(false) @@ -37,13 +39,15 @@ export default function RoleSelectPage() { return () => clearInterval(interval) }, []) + const setRoleMutation = useSetUserRole() + /** * Persists the chosen role, then moves on to the matching dashboard. * * The role is authorization state, so it lives in Postgres rather than * `localStorage` — navigation only happens once the API confirms the write. */ - const handleSelect = async (role: 'student' | 'recruiter') => { + const handleSelect = async (role: SelfAssignableRole) => { if (isSaving) return setSelectedRole(role) @@ -51,7 +55,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..29859c8 100644 --- a/apps/web/app/(onboarding)/recruiter/page.tsx +++ b/apps/web/app/(onboarding)/recruiter/page.tsx @@ -1,9 +1,29 @@ 'use client' -import { ArrowLeft, ArrowRight, Building2, Globe, Mail, MapPin, Sparkles, X } from 'lucide-react' +import { zodResolver } from '@hookform/resolvers/zod' +import { + ArrowLeft, + ArrowRight, + Briefcase, + Building2, + Globe, + Mail, + MapPin, + Search, + Sparkles, + UserCheck, + Users, + X, +} from 'lucide-react' import { useRouter } from 'next/navigation' import { useState } from 'react' -import confetti from 'canvas-confetti' +import { useForm } from 'react-hook-form' +import { useSaveRecruiterProfile } from '@/lib/hooks/use-onboarding' +import { + recruiterOnboardingSchema, + type RecruiterOnboardingInput, +} from '@/lib/schemas/recruiter-onboarding.schema' +import { showConfetti, ONBOARDING_REDIRECT_DELAY_MS } from '@/lib/utils/confetti' export interface RecruiterProfileData { companyName: string @@ -12,56 +32,44 @@ export interface RecruiterProfileData { headquartersLocation: string } +const DEFAULT_VALUES: RecruiterOnboardingInput = { + companyName: '', + companyMail: '', + companyUrl: '', + headquartersLocation: '', +} + 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 - } + const saveRecruiterMutation = useSaveRecruiterProfile() - if (!companyMail.trim()) { - setErrorMsg('Please enter your company work email.') - return - } + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(recruiterOnboardingSchema), + defaultValues: DEFAULT_VALUES, + }) + 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) + showConfetti() + + setTimeout(() => { + router.push('/landing') + }, ONBOARDING_REDIRECT_DELAY_MS) + } catch (err: unknown) { + if (err instanceof Error) { + setErrorMsg(err.message) + } } - - setTimeout(() => { - router.push('/landing') - }, 1400) } const handleBack = () => { @@ -91,175 +99,48 @@ export default function RecruiterOnboardingPage() { {/* LEFT COLUMN: HERO & RECRUITER TALENT SEARCH ILLUSTRATION */} {/* ========================================================================= */}
- {/* Top Illustration Scene */} -
- - - - - - - - - - - - - - - - - - - - {/* Ground plane shadow */} - - - {/* 3 Green Team Members / Candidate Silhouettes in Background */} - - {/* Left Candidate (Soft mint green) */} - - - - - - {/* Right Candidate (Soft mint green) */} - - - - - - {/* Center Main Candidate (Vibrant emerald green) */} - - - - - - {/* Sparkles / Radiating Accents */} - - - - - - {/* Laptop in Foreground with Candidate Search Screen */} - - {/* Open Screen Body */} - - - {/* Inner Display Screen */} - - - {/* Candidate Row 1 */} - - - - - - - - - {/* Candidate Row 2 */} - - - - - - - - - {/* Candidate Row 3 */} - - - - - - - + {/* Top Illustration Scene using Lucide Icons */} +
+ {/* Background Glow */} +
+ + {/* Hero Icon Card Showcase */} +
+ {/* Candidate Row 1 */} +
+
+ +
+
+
+
+
+ +
- {/* Magnifying Glass Over Candidate Search Screen */} - - {/* Glass Circle */} - - {/* Glass Specular Arc */} - - {/* Handle */} - - + {/* Candidate Row 2 */} +
+
+ +
+
+
+
+
+
- {/* Laptop Base Keyboard Deck */} - - {/* Base Trackpad Notch */} - - - + {/* Candidate Row 3 */} +
+
+ +
+
+
+
+
+ +
+
{/* Bottom Hero Text */} @@ -304,7 +185,8 @@ export default function RecruiterOnboardingPage() { {/* 4 Form Fields Form */} {/* Field 1: Company Name */} @@ -316,13 +198,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 +219,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 +244,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 +265,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 +292,8 @@ export default function RecruiterOnboardingPage() { {/* Complete Setup Button */}
- {step1Error && ( -
- {step1Error} + {(step1Error || errors.fullName || errors.headline) && ( +
+ {step1Error || errors.fullName?.message || errors.headline?.message}
)} @@ -577,8 +604,7 @@ export default function StudentOnboardingPage() { setFullName(e.target.value)} + {...register('fullName')} placeholder="e.g. Alex Chen" className="w-full px-3.5 py-2 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" /> @@ -597,8 +623,7 @@ export default function StudentOnboardingPage() { setHeadline(e.target.value)} + {...register('headline')} placeholder="e.g. CS Student | Full-Stack Developer | Open to Internships" className="w-full px-3.5 py-2 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" /> @@ -615,8 +640,7 @@ export default function StudentOnboardingPage() {