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 */}
-
- {step1Error && (
-
- {step1Error}
+ {(step1Error || errors.fullName || errors.headline) && (
+
+ {step1Error || errors.fullName?.message || errors.headline?.message}
)}
@@ -577,8 +612,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 +631,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 +648,7 @@ export default function StudentOnboardingPage() {
- {step2Error && (
-
- {step2Error}
+ {(step2Error || errors.school || errors.degree) && (
+
+ {step2Error || errors.school?.message || errors.degree?.message}
)}
@@ -658,8 +693,7 @@ export default function StudentOnboardingPage() {
setSchool(e.target.value)}
+ {...register('school')}
placeholder="e.g. Stanford University or MIT"
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"
/>
@@ -672,8 +706,7 @@ export default function StudentOnboardingPage() {
setDegree(e.target.value)}
+ {...register('degree')}
placeholder="e.g. B.S. Computer Science"
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"
/>
@@ -684,8 +717,7 @@ export default function StudentOnboardingPage() {
Graduation Year
*
{/* Resume */}
@@ -978,6 +970,11 @@ export default function StudentOnboardingPage() {
className="w-full pl-9 pr-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"
/>
+ {errors.resumeUrl && (
+
+ {errors.resumeUrl.message}
+
+ )}
diff --git a/apps/web/lib/hooks/use-auth.ts b/apps/web/lib/hooks/use-auth.ts
index 31420ba..10aa450 100644
--- a/apps/web/lib/hooks/use-auth.ts
+++ b/apps/web/lib/hooks/use-auth.ts
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { authClient } from '@/lib/auth/client'
import { apiFetch } from '@/lib/api-client'
+import { type SelfAssignableRole } from '@/lib/schemas/auth.schema'
import { ONBOARDING_QUERY_KEYS } from './use-onboarding'
export interface SocialSignInOptions {
@@ -35,7 +36,7 @@ export function useSetUserRole() {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: async (role: 'student' | 'recruiter') => {
+ mutationFn: async (role: SelfAssignableRole) => {
const data = await apiFetch<{ user: unknown }>('/api/users/me/role', {
method: 'PATCH',
body: { role },
diff --git a/apps/web/lib/hooks/use-onboarding.ts b/apps/web/lib/hooks/use-onboarding.ts
index e962046..e745ce9 100644
--- a/apps/web/lib/hooks/use-onboarding.ts
+++ b/apps/web/lib/hooks/use-onboarding.ts
@@ -2,6 +2,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
+import type { SelfAssignableRole } from '@/lib/schemas/auth.schema'
import type { StudentOnboardingInput } from '@/lib/schemas/student-onboarding.schema'
import type { RecruiterOnboardingInput } from '@/lib/schemas/recruiter-onboarding.schema'
import { apiFetch } from '@/lib/api-client'
@@ -12,60 +13,35 @@ export const ONBOARDING_QUERY_KEYS = {
currentUser: ['user', 'me'],
}
-export function useSaveStudentProfile() {
+export function useSaveProfile(
+ role: SelfAssignableRole,
+) {
const queryClient = useQueryClient()
+ const roleLabel = role === 'student' ? 'Student' : 'Recruiter'
- return useMutation({
- mutationFn: async (data: StudentOnboardingInput) => {
- localStorage.setItem('student_profile', JSON.stringify(data))
- localStorage.setItem('user_role', 'student')
-
- try {
- await apiFetch('/api/users/me/role', {
- method: 'PATCH',
- body: { role: 'student' },
- })
- } catch {
- // Backend sync is optional if auth token is expired or running standalone
- }
+ return useMutation({
+ mutationFn: async (data: TData) => {
+ await apiFetch('/api/users/me/role', {
+ method: 'PATCH',
+ body: { role },
+ })
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ONBOARDING_QUERY_KEYS.currentUser })
- toast.success('Student profile created successfully!')
+ toast.success(`${roleLabel} profile created successfully!`)
},
onError: (err: Error) => {
- toast.error(err.message || 'Failed to save student profile.')
+ toast.error(err.message || `Failed to save ${role} profile.`)
},
})
}
-export function useSaveRecruiterProfile() {
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: async (data: RecruiterOnboardingInput) => {
- localStorage.setItem('recruiter_profile', JSON.stringify(data))
- localStorage.setItem('user_role', 'recruiter')
-
- try {
- await apiFetch('/api/users/me/role', {
- method: 'PATCH',
- body: { role: 'recruiter' },
- })
- } catch {
- // Backend sync is optional if auth token is expired or running standalone
- }
+export function useSaveStudentProfile() {
+ return useSaveProfile('student')
+}
- return data
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ONBOARDING_QUERY_KEYS.currentUser })
- toast.success('Recruiter profile created successfully!')
- },
- onError: (err: Error) => {
- toast.error(err.message || 'Failed to save recruiter profile.')
- },
- })
+export function useSaveRecruiterProfile() {
+ return useSaveProfile('recruiter')
}
diff --git a/apps/web/lib/schemas/auth.schema.ts b/apps/web/lib/schemas/auth.schema.ts
index c4815f5..6870063 100644
--- a/apps/web/lib/schemas/auth.schema.ts
+++ b/apps/web/lib/schemas/auth.schema.ts
@@ -1,5 +1,8 @@
import { z } from 'zod'
+export const selfAssignableRoleSchema = z.enum(['student', 'recruiter'])
+export type SelfAssignableRole = z.infer
+
export const loginSchema = z.object({
name: z.string().optional(),
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
diff --git a/apps/web/lib/utils/confetti.ts b/apps/web/lib/utils/confetti.ts
new file mode 100644
index 0000000..52a11e2
--- /dev/null
+++ b/apps/web/lib/utils/confetti.ts
@@ -0,0 +1,18 @@
+import confetti, { type Options } from 'canvas-confetti'
+
+export const CONFETTI_COLORS = ['#00C26D', '#34D399', '#10B981', '#3B82F6', '#6366F1']
+export const ONBOARDING_REDIRECT_DELAY_MS = 1400
+
+export function showConfetti(options?: Options) {
+ try {
+ void confetti({
+ particleCount: 95,
+ spread: 75,
+ origin: { y: 0.6 },
+ colors: CONFETTI_COLORS,
+ ...options,
+ })
+ } catch {
+ // Fallback when canvas environment is unavailable
+ }
+}
From da7971809268c1466fe4660de6d0b5446c51b804 Mon Sep 17 00:00:00 2001
From: Chaithra S
Date: Sun, 6 Sep 2026 20:17:51 +0530
Subject: [PATCH 3/3] Disable sound effects in login UI
---
apps/web/app/(auth)/login/CharactersScene.tsx | 22 -------------------
apps/web/app/(auth)/login/audio.ts | 2 +-
apps/web/app/(auth)/login/page.tsx | 19 +++++-----------
3 files changed, 7 insertions(+), 36 deletions(-)
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.tsx b/apps/web/app/(auth)/login/page.tsx
index 46fa133..c787231 100644
--- a/apps/web/app/(auth)/login/page.tsx
+++ b/apps/web/app/(auth)/login/page.tsx
@@ -8,9 +8,9 @@ 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 (
@@ -71,7 +71,6 @@ export default function LoginPage() {
setIsRedirecting(false)
setErrorMsg(message)
setState((s) => ({ ...s, isError: true }))
- sound.playError()
}
const onSubmit = () => {
@@ -82,7 +81,6 @@ export default function LoginPage() {
setIsRedirecting(true)
setErrorMsg('')
setState((s) => ({ ...s, isError: false }))
- sound.playPop(520)
try {
await socialSignInMutation.mutateAsync({
@@ -157,7 +155,6 @@ export default function LoginPage() {
{...register('name')}
onFocus={() => {
setState((s) => ({ ...s, focusedField: 'email' }))
- sound.playPop(380)
}}
onBlur={() => setState((s) => ({ ...s, focusedField: 'none' }))}
placeholder="Your full name"
@@ -175,21 +172,19 @@ export default function LoginPage() {
onChange: (e) => {
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'
- }`}
+ 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',
+ )}
/>
@@ -244,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"
>
@@ -259,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"
>