├── .gitignore ├── .txt ├── LICENSE ├── README.md ├── app ├── (auth) │ ├── layout.tsx │ ├── sign-in │ │ └── page.tsx │ └── sign-up │ │ └── page.tsx ├── (dash) │ ├── dashboard │ │ └── page.tsx │ ├── interview │ │ ├── [id] │ │ │ ├── feedback │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── page.tsx │ └── layout.tsx ├── (root) │ ├── layout.tsx │ └── page.tsx ├── api │ └── google-auth │ │ ├── route.ts │ │ └── vapi │ │ └── generate │ │ └── route.ts ├── favicon.ico ├── globals.css └── layout.tsx ├── components.json ├── components ├── Agent.tsx ├── AuthForm.tsx ├── BackToDashboardButton.tsx ├── CheckFeedbackInterviewButton.tsx ├── DisplayTechicons.tsx ├── Features.tsx ├── Footer.tsx ├── FormField.tsx ├── GetInterview.tsx ├── GetStartedbtn.tsx ├── Github.tsx ├── HomeLoginbtn.tsx ├── HomePlay.tsx ├── InterviewCard.tsx ├── Loader.tsx ├── Loading.tsx ├── RetakeInterviewButton.tsx ├── SignOutButton.tsx ├── StartInterviewButton.tsx ├── Testimonials.tsx └── ui │ ├── InfiniteCards.tsx │ ├── button.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ └── sonner.tsx ├── constants ├── feature.ts ├── index.ts └── testimonials.ts ├── eslint.config.mjs ├── firebass ├── admin.ts └── client.ts ├── lib ├── actions │ ├── auth.action.ts │ └── generate.action.ts ├── utils.ts └── vapi.sdk.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── Chloe RT600.webp ├── Connor.webp ├── ai-avatar.png ├── calendar.svg ├── chloe.webp ├── covers │ ├── accenture.png │ ├── adobe.png │ ├── amazon.png │ ├── facebook.png │ ├── flipkart.png │ ├── google.png │ ├── hcl.jpg │ ├── infosys.jpg │ ├── microsoft.png │ ├── pinterest.png │ ├── quora.png │ ├── reddit.png │ ├── skype.png │ ├── spotify.png │ ├── tcs.png │ ├── telegram.png │ └── wipro.png ├── file.svg ├── githubnav.png ├── globe.svg ├── google-icon.png ├── hero.png.webp ├── kara.webp ├── logo.svg ├── logout.png ├── pattern.png ├── profile.svg ├── profile1.webp ├── profile2.webp ├── profile3.webp ├── profile4.webp ├── profile5.webp ├── profile6.webp ├── react.svg ├── star.svg ├── tailwind.svg ├── tech.svg ├── upload.svg ├── user-avatar.png └── window.svg ├── tsconfig.json └── types ├── index.d.ts └── vapi.d.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /.txt: -------------------------------------------------------------------------------- 1 | npx create-nextapp@ /. 2 | npx shadcn@latest init 3 | npm run dev 4 | npx shadcn@latest add button input sonner 5 | npm install dayjs 6 | npm install firebase 7 | npm install firebase-admin --save 8 | npm install ai @ai-sdk/google 9 | npm install @vapi-ai/web -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Abhishek Ganvir 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 🤖 IntervueAI - Precision in Recruitment 3 | ![intervueai](https://github.com/user-attachments/assets/d26c8001-18ba-4777-bcd9-f80c479f3557) 4 | 5 | 6 | 7 | 8 | ## 🌟 Overview 9 | The **IntervueAI** is a next-gen AI-powered mock interview platform built to help students and professionals prepare for interviews in a way that actually feels real. It goes beyond generic questions using your resume, job role, and round type to generate personalized, industry-relevant interviews. 10 | 11 | What makes IntervueAI special is its ability to give smart, AI-generated feedback after each session including performance insights, improvement tips, and evaluation reports so you’re not just practicing, you’re leveling up. 12 | 13 | ## ✨ Features 14 | - 🔐 **User Authentication** 15 | - Sign Up and Sign In using password/email authentication handled by Firebase. 16 | 17 | - 🎛️ **Interactive Dashboard** 18 | - Provides an intuitive interface for managing interview preparations. 19 | 20 | - 🧑‍💻 **No Form-Based Flow** 21 | - Say goodbye to static input forms just log in, speak, and start your interview. It's natural, voice-first interaction from the start. 22 | 23 | - 🤖 **AI-Powered Interview Generation** 24 | - Personalized mock interviews based on your resume, job role, and round type using Vapi voice assistants and Google Gemini. 25 | 26 | 27 | - 🧠 **Digital AI Interviewers** 28 | - Interact with intelligent AI personas inspired by iconic digital characters, making practice more immersive and less robotic. 29 | 30 | - 📝 **Real-Time Transcription** 31 | - Get instant AI-generated feedback after each session, including ratings, improvement tips, and evaluation summaries. 32 | 33 | 34 | 35 | ## 🛠️ Tech Stack 36 | - ⚛ **Next.js** 37 | - 🔥 **Firebase** 38 | - 🎨 **Tailwind CSS** 39 | - 🗣️ **Vapi AI** 40 | - 🧩 **shadcn/ui** 41 | - 🧠 **Google Gemini** 42 | 43 | 44 | ## 🤸 Quick Start 45 | 46 | Follow these steps to set up the project locally on your machine. 47 | 48 | 49 | **Installation** 50 | 51 | Install the project dependencies using npm: 52 | 53 | ```bash 54 | npm install 55 | ``` 56 | 57 | **Set Up Environment Variables** 58 | 59 | Create a new file named `.env.local` in the root of your project and add the following content: 60 | 61 | ```env 62 | NEXT_PUBLIC_VAPI_WEB_TOKEN= 63 | NEXT_PUBLIC_VAPI_WORKFLOW_ID= 64 | 65 | GOOGLE_GENERATIVE_AI_API_KEY= 66 | 67 | NEXT_PUBLIC_BASE_URL= 68 | 69 | NEXT_PUBLIC_FIREBASE_API_KEY= 70 | NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= 71 | NEXT_PUBLIC_FIREBASE_PROJECT_ID= 72 | NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= 73 | NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= 74 | NEXT_PUBLIC_FIREBASE_APP_ID= 75 | 76 | FIREBASE_PROJECT_ID= 77 | FIREBASE_CLIENT_EMAIL= 78 | FIREBASE_PRIVATE_KEY= 79 | ``` 80 | 81 | Replace the placeholder values with your actual **[Firebase](https://firebase.google.com/)**, **[Vapi](https://vapi.ai/?utm_source=youtube&utm_medium=video&utm_campaign=jsmastery_recruitingpractice&utm_content=paid_partner&utm_term=recruitingpractice)** credentials. 82 | 83 | **Running the Project** 84 | 85 | ```bash 86 | npm run dev 87 | ``` 88 | 89 | Open [http://localhost:3000](http://localhost:3000) in your browser to view the project. 90 | 91 | ## 🤝 Contributions 92 | This project is **open-source forever!** Contributions are welcome. Feel free to: 93 | - 🎨 Improve UI/UX 94 | - 🧠 Optimize AI algorithms 95 | - 🗄️ Enhance database efficiency 96 | - 🚀 Add new features 97 | 98 | Fork the repository, make changes, and submit a **pull request**! 99 | 100 | ## 📜 License 101 | This project is licensed under the **MIT License**. 102 | 103 | --- 104 | 105 | ### **🎉 Happy Coding & Best of Luck for Your Interviews! 🚀** 106 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | 3 | import { isAuthenticated } from '@/lib/actions/auth.action' 4 | import { redirect } from 'next/navigation'; 5 | const Authlayout = async ({children} : {children: ReactNode}) => { 6 | const isUserAuthenticated = await isAuthenticated(); 7 | if (isUserAuthenticated) redirect('/dashboard'); 8 | return ( 9 |
{children}
10 | ) 11 | } 12 | export default Authlayout -------------------------------------------------------------------------------- /app/(auth)/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import AuthForm from '@/components/AuthForm' 2 | import React from 'react' 3 | 4 | const page = () => { 5 | return 6 | } 7 | 8 | export default page -------------------------------------------------------------------------------- /app/(auth)/sign-up/page.tsx: -------------------------------------------------------------------------------- 1 | import AuthForm from '@/components/AuthForm' 2 | import React from 'react' 3 | 4 | const page = () => { 5 | return 6 | } 7 | 8 | export default page -------------------------------------------------------------------------------- /app/(dash)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | 2 | import InterviewCard from "@/components/InterviewCard" 3 | import StartInterviewButton from "@/components/StartInterviewButton" 4 | import { Button } from "@/components/ui/button" 5 | import {getCurrentUser} from "@/lib/actions/auth.action" 6 | import { getInterviewByUserId, getLatestInterviews } from "@/lib/actions/generate.action" 7 | import Image from "next/image" 8 | 9 | 10 | const page = async () => { 11 | const user = await getCurrentUser(); 12 | const [ userInterviews,latestInterviews ] = await Promise.all([ 13 | await getInterviewByUserId(user?.id!), 14 | await getLatestInterviews({userId: user?.id! }) 15 | ]) 16 | 17 | const hasPastInterviews = userInterviews?.length > 0; 18 | const hasUpcomingInterviews = latestInterviews?.length > 0; 19 | 20 | return ( 21 | <> 22 |
23 |
24 | 25 |
Get Interview-Ready with Smart Practice and Real Feedback
26 | 27 |

Because every word counts when the job’s on the line

28 | Kara 29 | 31 | 32 |
33 | 34 | Kara 35 |
36 |
37 |

Your Interviews

38 |
39 |
{ 40 | hasPastInterviews ? ( 41 | userInterviews?.map((interview) => ( 42 |
43 |
44 | ))) : (

You haven’t taken any Interviews yet

) } 45 | {/**/} 46 |
47 |
48 |
49 |
50 |

Take an Interview

51 |
52 |
53 | { 54 | hasUpcomingInterviews ? ( 55 | latestInterviews?.map((interview) => ( 56 |
57 |
58 | ))) : (

There are no interviews available

) } 59 | {/**/} 60 |
61 |
62 |
63 | 64 | ) 65 | } 66 | 67 | export default page -------------------------------------------------------------------------------- /app/(dash)/interview/[id]/feedback/page.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import Link from "next/link"; 3 | import Image from "next/image"; 4 | import { redirect } from "next/navigation"; 5 | 6 | import { 7 | getFeedbackByInterviewId, 8 | getInterviewById, 9 | } from "@/lib/actions/generate.action" 10 | import { Button } from "@/components/ui/button"; 11 | import { getCurrentUser } from "@/lib/actions/auth.action"; 12 | import BackToDashboardButton from "@/components/BackToDashboardButton"; 13 | import RetakeInterviewButton from "@/components/RetakeInterviewButton"; 14 | 15 | const Feedback = async ({ params }: RouteParams) => { 16 | const { id } = await params; 17 | const user = await getCurrentUser(); 18 | 19 | const interview = await getInterviewById(id); 20 | if (!interview) redirect("/"); 21 | 22 | const feedback = await getFeedbackByInterviewId({ 23 | interviewId: id, 24 | userId: user?.id!, 25 | }); 26 | 27 | return ( 28 |
29 |
30 |

31 | Feedback on the Interview -{" "} 32 | {interview.role} Interview 33 |

34 |
35 | 36 |
37 |
38 | {/* Overall Impression */} 39 |
40 | star 41 |

42 | Overall Impression:{" "} 43 | 44 | {feedback?.totalScore} 45 | 46 | /100 47 |

48 |
49 | 50 | {/* Date */} 51 |
52 | calendar 53 |

54 | {feedback?.createdAt 55 | ? dayjs(feedback.createdAt).format("MMM D, YYYY h:mm A") 56 | : "N/A"} 57 |

58 |
59 |
60 |
61 | 62 |
63 | 64 |

{feedback?.finalAssessment}

65 | 66 | {/* Interview Breakdown */} 67 |
68 |

Breakdown of the Interview:

69 | {feedback?.categoryScores?.map((category, index) => ( 70 |
71 |

72 | {index + 1}. {category.name} ({category.score}/100) 73 |

74 |

{category.comment}

75 |
76 | ))} 77 |
78 | 79 |
80 |

Strengths

81 |
    82 | {feedback?.strengths?.map((strength, index) => ( 83 |
  • {strength}
  • 84 | ))} 85 |
86 |
87 | 88 |
89 |

Areas for Improvement

90 |
    91 | {feedback?.areasForImprovement?.map((area, index) => ( 92 |
  • {area}
  • 93 | ))} 94 |
95 |
96 | 97 |
98 | 99 | 100 |
101 |
102 | ); 103 | }; 104 | 105 | export default Feedback; -------------------------------------------------------------------------------- /app/(dash)/interview/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | 2 | import GetInterview from "@/components/GetInterview" 3 | import DisplayTechicons from "@/components/DisplayTechicons"; 4 | import { getCurrentUser } from "@/lib/actions/auth.action"; 5 | import { getInterviewById } from "@/lib/actions/generate.action"; 6 | import { getRandomInterviewCover } from "@/lib/utils"; 7 | import Image from "next/image"; 8 | import { redirect } from "next/navigation"; 9 | 10 | const page = async ({ params }: RouteParams) => { 11 | const {id} = await params; 12 | const user = await getCurrentUser(); 13 | const interview = await getInterviewById(id); 14 | if(!interview) redirect('/dashboard') 15 | return ( 16 | <> 17 |
18 |
19 |
20 | cover-image 21 |

{interview.role}

22 |
23 | 24 |
25 | 26 |
27 | 28 |

{interview.type}

29 | 30 |
31 |
32 | 33 |
34 | 35 | 42 | 43 | ) 44 | } 45 | 46 | export default page -------------------------------------------------------------------------------- /app/(dash)/interview/page.tsx: -------------------------------------------------------------------------------- 1 | import Agent from '@/components/Agent' 2 | import { getCurrentUser } from '@/lib/actions/auth.action' 3 | import React from 'react' 4 | 5 | const page = async () => { 6 | const user = await getCurrentUser(); 7 | return ( 8 | <> 9 |

Interview Generation

10 | 11 | 12 | ) 13 | } 14 | 15 | export default page -------------------------------------------------------------------------------- /app/(dash)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import Link from 'next/link' 3 | import React, { ReactNode } from 'react' 4 | import { isAuthenticated } from '@/lib/actions/auth.action' 5 | import { redirect } from 'next/navigation'; 6 | import SignOutButton from '@/components/SignOutButton'; 7 | const Rootlayout = async ({children} : {children: ReactNode}) => { 8 | const isUserAuthenticated = await isAuthenticated(); 9 | if (!isUserAuthenticated) redirect('/'); 10 | return ( 11 |
12 | 19 | {children} 20 |
21 | ) 22 | } 23 | 24 | export default Rootlayout -------------------------------------------------------------------------------- /app/(root)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import { isAuthenticated } from '@/lib/actions/auth.action' 3 | import { redirect } from 'next/navigation'; 4 | import Link from 'next/link'; 5 | import Image from 'next/image'; 6 | import HomeLoginbtn from '@/components/HomeLoginbtn'; 7 | const Rootlayout = async ({children} : {children: ReactNode}) => { 8 | const isUserAuthenticated = await isAuthenticated(); 9 | if (isUserAuthenticated) redirect('/dashboard'); 10 | return ( 11 |
12 | 21 | {children} 22 |
23 | ) 24 | } 25 | 26 | export default Rootlayout -------------------------------------------------------------------------------- /app/(root)/page.tsx: -------------------------------------------------------------------------------- 1 | // app/page.tsx 2 | "use client" 3 | import Features from '@/components/Features'; 4 | import Footer from '@/components/Footer'; 5 | import GetStartedbtn from '@/components/GetStartedbtn'; 6 | import HomePlay from '@/components/HomePlay'; 7 | import Testimonials from '@/components/Testimonials'; 8 | 9 | //import { isAuthenticated } from '@/lib/actions/auth.action' 10 | import Link from 'next/link' 11 | //import { redirect } from 'next/navigation' 12 | import React, { useEffect, useState } from 'react'; 13 | 14 | export default function LandingPage() { 15 | const [mounted, setMounted] = useState(false); 16 | 17 | useEffect(() => { 18 | setMounted(true); 19 | }, []); 20 | 21 | if (!mounted) return null; 22 | return ( 23 | <> 24 | <> 25 |
26 |
27 |
Where everyone 28 |
29 | suffers together
30 |
31 |

We know how brutal interviews can be. They don’t have to be.

32 |

Generate personalized mock interviews, watch how others handled theirs,

33 |

and get feedback that actually helps. 34 |

35 |
36 |
37 |
38 |
39 |
40 | 41 | < GetStartedbtn /> 42 | 43 |
44 |
45 | 46 |
47 | < HomePlay /> 48 |
49 | 50 | 51 | {/* This wrapper shouldn't limit full-screen sections */} 52 |
53 | 54 | {/* Normal content (centered) */} 55 |
56 | {/* ...hero text */} 57 |
WHY CHOOSE US
58 |
Unleash Your Potential with AI
59 |
Take your interview preparation to the next level with features designed for success.
60 |
61 | 62 | {/* Full-screen wide section */} 63 |
64 |
65 | 66 |
67 |
68 |
69 |
70 | 71 |
72 | 73 |
74 |
75 |
76 | 77 | 78 | 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /app/api/google-auth/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/google-auth/route.ts 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { db } from "@/firebass/admin"; 4 | import { setSessionCookie } from "@/lib/actions/auth.action"; 5 | 6 | export async function POST(req: NextRequest) { 7 | try { 8 | const { uid, name, email, idToken } = await req.json(); 9 | 10 | // Check if user exists in Firestore 11 | const userRef = db.collection("users").doc(uid); 12 | const userDoc = await userRef.get(); 13 | 14 | if (!userDoc.exists) { 15 | // Save to Firestore if new user 16 | await userRef.set({ 17 | name, 18 | email, 19 | }); 20 | } 21 | 22 | // Set session cookie 23 | await setSessionCookie(idToken); 24 | 25 | return NextResponse.json({ success: true, message: "User signed in successfully." }); 26 | } catch (error) { 27 | console.error("Google Auth error:", error); 28 | return NextResponse.json({ success: false, message: "Something went wrong." }, { status: 500 }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/api/google-auth/vapi/generate/route.ts: -------------------------------------------------------------------------------- 1 | import { generateText } from "ai"; 2 | import { google } from "@ai-sdk/google"; 3 | 4 | 5 | import { getRandomInterviewCover } from "@/lib/utils"; 6 | import { db } from "@/firebass/admin"; 7 | 8 | export async function GET() { 9 | return Response.json({ success: true, data: 'THANK YOU!'}, {status:200}); 10 | } 11 | 12 | export async function POST(request: Request){ 13 | const { type, role , level, techstack, amount, profile, userid } = await request.json(); 14 | 15 | try { 16 | const { text: questions } = await generateText({ 17 | model: google("gemini-2.0-flash-001"), 18 | prompt: `Prepare questions for a job interview. 19 | The job role is ${role}. 20 | The job experience level is ${level}. 21 | The tech stack used in the job is: ${techstack}. 22 | The focus between behavioural and technical questions should lean towards: ${type}. 23 | The user's cv/resume profile is: ${profile}. 24 | The amount of questions required is: ${amount}. 25 | Please return only the questions, without any additional text. 26 | The questions are going to be read by a voice assistant so do not use "/" or "*" or any other special characters which might break the voice assistant. 27 | Return the questions formatted like this: 28 | ["Question 1", "Question 2", "Question 3"] 29 | 30 | Thank you! <3 31 | `, 32 | }); 33 | const interview = { 34 | role: role, 35 | type: type, 36 | level: level, 37 | profile: profile, 38 | techstack: techstack.split(","), 39 | questions: JSON.parse(questions), 40 | userId: userid, 41 | finalized: true, 42 | coverImage: getRandomInterviewCover(), 43 | createdAt: new Date().toISOString(), 44 | }; 45 | 46 | await db.collection("interviews").add(interview); 47 | 48 | return Response.json({success: true}, {status:200}) 49 | 50 | } catch (error) { 51 | console.error(error); 52 | } 53 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AbhishekGanvir/IntervueAI/b8fa3b1c6971998fbecc2f0c104bfb22cf12257c/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @plugin "tailwindcss-animate"; 4 | 5 | @custom-variant dark (&:is(.dark *)); 6 | 7 | @theme { 8 | --color-success-100: #49de50; 9 | --color-success-200: #42c748; 10 | --color-destructive-100: #f75353; 11 | --color-destructive-200: #c44141; 12 | 13 | --color-primary-100: #dddfff; 14 | --color-primary-200: #cac5fe; 15 | 16 | --color-light-100: #d6e0ff; 17 | --color-light-400: #6870a6; 18 | --color-light-600: #4f557d; 19 | --color-light-800: #24273a; 20 | 21 | --color-dark-100: #020408; 22 | --color-dark-200: #27282f; 23 | --color-dark-300: #242633; 24 | 25 | --font-mona-sans: "Mona Sans", sans-serif; 26 | 27 | --bg-pattern: url("/pattern.png"); 28 | } 29 | 30 | :root { 31 | --radius: 0.625rem; 32 | --background: oklch(1 0 0); 33 | --foreground: oklch(0.145 0 0); 34 | --card: oklch(1 0 0); 35 | --card-foreground: oklch(0.145 0 0); 36 | --popover: oklch(1 0 0); 37 | --popover-foreground: oklch(0.145 0 0); 38 | --primary: oklch(0.205 0 0); 39 | --primary-foreground: oklch(0.985 0 0); 40 | --secondary: oklch(0.97 0 0); 41 | --secondary-foreground: oklch(0.205 0 0); 42 | --muted: oklch(0.97 0 0); 43 | --muted-foreground: oklch(0.556 0 0); 44 | --accent: oklch(0.97 0 0); 45 | --accent-foreground: oklch(0.205 0 0); 46 | --destructive: oklch(0.577 0.245 27.325); 47 | --border: oklch(0.922 0 0); 48 | --input: oklch(0.922 0 0); 49 | --ring: oklch(0.708 0 0); 50 | --chart-1: oklch(0.646 0.222 41.116); 51 | --chart-2: oklch(0.6 0.118 184.704); 52 | --chart-3: oklch(0.398 0.07 227.392); 53 | --chart-4: oklch(0.828 0.189 84.429); 54 | --chart-5: oklch(0.769 0.188 70.08); 55 | --sidebar: oklch(0.985 0 0); 56 | --sidebar-foreground: oklch(0.145 0 0); 57 | --sidebar-primary: oklch(0.205 0 0); 58 | --sidebar-primary-foreground: oklch(0.985 0 0); 59 | --sidebar-accent: oklch(0.97 0 0); 60 | --sidebar-accent-foreground: oklch(0.205 0 0); 61 | --sidebar-border: oklch(0.922 0 0); 62 | --sidebar-ring: oklch(0.708 0 0); 63 | } 64 | 65 | .dark { 66 | --background: oklch(0.145 0 0); 67 | --foreground: oklch(0.985 0 0); 68 | --card: oklch(0.205 0 0); 69 | --card-foreground: oklch(0.985 0 0); 70 | --popover: oklch(0.205 0 0); 71 | --popover-foreground: oklch(0.985 0 0); 72 | --primary: oklch(0.922 0 0); 73 | --primary-foreground: oklch(0.205 0 0); 74 | --secondary: oklch(0.269 0 0); 75 | --secondary-foreground: oklch(0.985 0 0); 76 | --muted: oklch(0.269 0 0); 77 | --muted-foreground: var(--light-100); 78 | --accent: oklch(0.269 0 0); 79 | --accent-foreground: oklch(0.985 0 0); 80 | --destructive: oklch(0.704 0.191 22.216); 81 | --border: oklch(1 0 0 / 10%); 82 | --input: oklch(1 0 0 / 15%); 83 | --ring: oklch(0.556 0 0); 84 | --chart-1: oklch(0.488 0.243 264.376); 85 | --chart-2: oklch(0.696 0.17 162.48); 86 | --chart-3: oklch(0.769 0.188 70.08); 87 | --chart-4: oklch(0.627 0.265 303.9); 88 | --chart-5: oklch(0.645 0.246 16.439); 89 | --sidebar: oklch(0.205 0 0); 90 | --sidebar-foreground: oklch(0.985 0 0); 91 | --sidebar-primary: oklch(0.488 0.243 264.376); 92 | --sidebar-primary-foreground: oklch(0.985 0 0); 93 | --sidebar-accent: oklch(0.269 0 0); 94 | --sidebar-accent-foreground: oklch(0.985 0 0); 95 | --sidebar-border: oklch(1 0 0 / 10%); 96 | --sidebar-ring: oklch(0.556 0 0); 97 | } 98 | 99 | @theme inline { 100 | --radius-sm: calc(var(--radius) - 4px); 101 | --radius-md: calc(var(--radius) - 2px); 102 | --radius-lg: var(--radius); 103 | --radius-xl: calc(var(--radius) + 4px); 104 | --color-background: var(--background); 105 | --color-foreground: var(--foreground); 106 | --color-card: var(--card); 107 | --color-card-foreground: var(--card-foreground); 108 | --color-popover: var(--popover); 109 | --color-popover-foreground: var(--popover-foreground); 110 | --color-primary: var(--primary); 111 | --color-primary-foreground: var(--primary-foreground); 112 | --color-secondary: var(--secondary); 113 | --color-secondary-foreground: var(--secondary-foreground); 114 | --color-muted: var(--muted); 115 | --color-muted-foreground: var(--muted-foreground); 116 | --color-accent: var(--accent); 117 | --color-accent-foreground: var(--accent-foreground); 118 | --color-destructive: var(--destructive); 119 | --color-border: var(--border); 120 | --color-input: var(--input); 121 | --color-ring: var(--ring); 122 | --color-chart-1: var(--chart-1); 123 | --color-chart-2: var(--chart-2); 124 | --color-chart-3: var(--chart-3); 125 | --color-chart-4: var(--chart-4); 126 | --color-chart-5: var(--chart-5); 127 | --color-sidebar: var(--sidebar); 128 | --color-sidebar-foreground: var(--sidebar-foreground); 129 | --color-sidebar-primary: var(--sidebar-primary); 130 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 131 | --color-sidebar-accent: var(--sidebar-accent); 132 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 133 | --color-sidebar-border: var(--sidebar-border); 134 | --color-sidebar-ring: var(--sidebar-ring); 135 | } 136 | 137 | @layer base { 138 | * { 139 | @apply border-border outline-ring/50; 140 | } 141 | body { 142 | @apply bg-background text-foreground; 143 | } 144 | p { 145 | @apply text-light-100; 146 | } 147 | h2 { 148 | @apply text-3xl font-semibold; 149 | } 150 | h3 { 151 | @apply text-2xl font-semibold; 152 | } 153 | ul { 154 | @apply list-disc list-inside; 155 | } 156 | li { 157 | @apply text-light-100; 158 | } 159 | } 160 | 161 | @layer components { 162 | .btn-call { 163 | @apply inline-block px-7 py-3 font-bold text-sm leading-5 text-white transition-colors duration-150 bg-success-100 border border-transparent rounded-full shadow-sm focus:outline-none focus:shadow-2xl active:bg-success-200 hover:bg-success-200 min-w-28 cursor-pointer items-center justify-center overflow-visible; 164 | 165 | .span { 166 | @apply bg-success-100 h-[85%] w-[65%]; 167 | } 168 | } 169 | 170 | .btn-disconnect { 171 | @apply inline-block px-7 py-3 text-sm font-bold leading-5 text-white transition-colors duration-150 bg-destructive-100 border border-transparent rounded-full shadow-sm focus:outline-none focus:shadow-2xl active:bg-destructive-200 hover:bg-destructive-200 min-w-28; 172 | } 173 | 174 | .btn-upload { 175 | @apply flex min-h-14 w-full items-center justify-center gap-1.5 rounded-md; 176 | } 177 | .btn-primary { 178 | @apply w-fit !bg-primary-200 !text-dark-100 hover:!bg-primary-200/80 !rounded-full !font-bold px-5 cursor-pointer min-h-10; 179 | } 180 | .btn-secondary { 181 | @apply w-fit !bg-dark-200 !text-primary-200 hover:!bg-dark-200/80 !rounded-full !font-bold px-5 cursor-pointer min-h-10; 182 | } 183 | 184 | .btn-upload { 185 | @apply bg-dark-200 rounded-full min-h-12 px-5 cursor-pointer border border-input overflow-hidden; 186 | } 187 | 188 | .card-border { 189 | @apply border-gradient p-0.5 rounded-2xl w-fit; 190 | } 191 | 192 | .card { 193 | @apply dark-gradient rounded-2xl min-h-full; 194 | } 195 | 196 | .form { 197 | @apply w-full; 198 | 199 | .label { 200 | @apply !text-light-100 !font-normal; 201 | } 202 | 203 | .input { 204 | @apply !bg-dark-200 !rounded-full !min-h-12 !px-5 placeholder:!text-light-100; 205 | } 206 | 207 | .btn { 208 | @apply !w-full !bg-primary-200 !text-dark-100 hover:!bg-primary-200/80 !rounded-full !min-h-10 !font-bold !px-5 cursor-pointer; 209 | } 210 | } 211 | 212 | .call-view { 213 | @apply flex sm:flex-row flex-col gap-10 items-center justify-between w-full; 214 | 215 | h3 { 216 | @apply text-center text-primary-100 mt-5; 217 | } 218 | 219 | .card-interviewer { 220 | @apply flex-center flex-col gap-2 p-7 h-[400px] blue-gradient-dark rounded-lg border-2 border-primary-200/50 flex-1 sm:basis-1/2 w-full; 221 | } 222 | 223 | .avatar { 224 | @apply z-10 flex items-center justify-center blue-gradient rounded-full size-[120px] relative; 225 | 226 | .animate-speak { 227 | @apply absolute inline-flex size-5/6 animate-ping rounded-full bg-primary-200 opacity-75; 228 | } 229 | } 230 | 231 | .card-border { 232 | @apply border-gradient p-0.5 rounded-2xl flex-1 sm:basis-1/2 w-full h-[400px] max-md:hidden; 233 | } 234 | 235 | .card-content { 236 | @apply flex flex-col gap-2 justify-center items-center p-7 dark-gradient rounded-2xl min-h-full; 237 | } 238 | } 239 | 240 | .transcript-border { 241 | @apply border-gradient p-0.5 rounded-2xl w-full; 242 | 243 | .transcript { 244 | @apply dark-gradient rounded-2xl min-h-12 px-5 py-3 flex items-center justify-center; 245 | 246 | p { 247 | @apply text-lg text-center text-white; 248 | } 249 | } 250 | } 251 | 252 | .section-feedback { 253 | @apply flex flex-col gap-8 max-w-5xl mx-auto max-sm:px-4 text-lg leading-7; 254 | 255 | .buttons { 256 | @apply flex w-full justify-evenly gap-4 max-sm:flex-col max-sm:items-center; 257 | } 258 | } 259 | 260 | .auth-layout { 261 | @apply flex items-center justify-center mx-auto max-w-7xl min-h-screen max-sm:px-4 max-sm:py-8; 262 | } 263 | 264 | .root-layout { 265 | @apply flex mx-auto max-w-7xl flex-col gap-12 my-12 px-16 max-sm:px-4 max-sm:my-8; 266 | } 267 | 268 | .card-cta { 269 | @apply flex flex-row blue-gradient-dark rounded-3xl px-16 py-6 items-center justify-between max-sm:px-4; 270 | } 271 | 272 | .interviews-section { 273 | @apply flex flex-wrap gap-4 max-lg:flex-col w-full items-stretch; 274 | } 275 | 276 | .interview-text { 277 | @apply text-lg text-center text-white; 278 | } 279 | 280 | .progress { 281 | @apply h-1.5 text-[5px] font-bold bg-primary-200 rounded-full flex-center; 282 | } 283 | 284 | .tech-tooltip { 285 | @apply absolute bottom-full mb-1 hidden group-hover:flex px-2 py-1 text-xs text-white bg-gray-700 rounded-md shadow-md; 286 | } 287 | 288 | .card-interview { 289 | @apply dark-gradient rounded-2xl min-h-full flex flex-col p-6 relative overflow-hidden gap-10 justify-between; 290 | 291 | .badge-text { 292 | @apply text-sm font-semibold capitalize; 293 | } 294 | } 295 | } 296 | 297 | @utility dark-gradient { 298 | @apply bg-gradient-to-b from-[#1A1C20] to-[#08090D]; 299 | } 300 | 301 | @utility border-gradient { 302 | @apply bg-gradient-to-b from-[#4B4D4F] to-[#4B4D4F33]; 303 | } 304 | 305 | @utility pattern { 306 | @apply bg-[url('/pattern.png')] bg-top bg-no-repeat; 307 | } 308 | 309 | @utility blue-gradient-dark { 310 | @apply bg-gradient-to-b from-[#171532] to-[#08090D]; 311 | } 312 | 313 | @utility blue-gradient { 314 | @apply bg-gradient-to-l from-[#FFFFFF] to-[#CAC5FE]; 315 | } 316 | 317 | @utility flex-center { 318 | @apply flex items-center justify-center; 319 | } 320 | 321 | @utility animate-fadeIn { 322 | animation: fadeIn 0.3s ease-in-out; 323 | } 324 | /* Hide scrollbar but allow scrolling */ 325 | ::-webkit-scrollbar { 326 | display: none; 327 | } 328 | 329 | /* Ensure scrolling still works */ 330 | body { 331 | scrollbar-width: none; /* Firefox */ 332 | -ms-overflow-style: none; /* Internet Explorer/Edge */ 333 | } 334 | 335 | @keyframes fadeIn { 336 | from { 337 | opacity: 0; 338 | transform: translateY(5px); 339 | } 340 | to { 341 | opacity: 1; 342 | transform: translateY(0); 343 | } 344 | } 345 | @media (min-width: 768px) and (max-width: 884px) { 346 | .interview-section { 347 | grid-template-columns: repeat(1, minmax(0, 1fr)); 348 | place-items: center; 349 | justify-content: center; 350 | 351 | } 352 | .interview-wrapper { 353 | display: flex; 354 | justify-content: center; 355 | } 356 | } 357 | 358 | @media (min-width: 1027px) and (max-width: 1245px) { 359 | .interview-section { 360 | grid-template-columns: repeat(2, minmax(0, 2fr)); 361 | } 362 | .interview-wrapper { 363 | display: flex; 364 | justify-content: center; 365 | } 366 | } 367 | @media (min-width: 1027px) and (max-width: 1245px) { 368 | .homeplaylayout{ 369 | margin-bottom: 20%; 370 | 371 | } 372 | } 373 | @media (min-width: 668px) and (max-width: 768px) { 374 | .homeplay-icon{ 375 | margin-bottom: -40%; 376 | } 377 | } 378 | @media (min-width: 800px) and (max-width: 900px) { 379 | .homeplay-icon{ 380 | margin-bottom: -28%; 381 | } 382 | } 383 | @media (min-width: 1000px) and (max-width: 1100px) { 384 | .homeplay-icon{ 385 | margin-bottom: -15%; 386 | } 387 | } 388 | @media (min-width: 902px) and (max-width: 980px) { 389 | .homeplay-icon{ 390 | margin-bottom: -20%; 391 | } 392 | } 393 | @media (min-width: 984px) and (max-width: 999px) { 394 | .homeplay-icon{ 395 | margin-bottom: -20%; 396 | } 397 | } 398 | @media (min-width: 1105px) and (max-width: 1248px) { 399 | .homeplay-icon{ 400 | margin-bottom: -1%; 401 | } 402 | } 403 | .glow-border { 404 | background: radial-gradient( 405 | ellipse at center, 406 | rgba(168, 85, 247, 0.5) 0%, 407 | transparent 40% 408 | ); 409 | filter: blur(40px); 410 | z-index: 0; 411 | } 412 | .hero-image-wrapper { 413 | perspective: 1000px; 414 | overflow: hidden; 415 | } 416 | 417 | .hero-image { 418 | transform: rotateX(15deg) scale(1); 419 | transition: transform 0.5s ease-out; 420 | will-change: transform; 421 | } 422 | 423 | .hero-image.scrolled { 424 | transform: rotateX(0deg) scale(1) translateY(40px); 425 | } 426 | @keyframes scrollAnimation { 427 | 0% { 428 | transform: translateX(0); 429 | } 430 | 100% { 431 | transform: translateX(-100%); 432 | } 433 | } 434 | 435 | .animate-scroll { 436 | animation: scrollAnimation var(--animation-duration) linear infinite; 437 | } 438 | @layer utilities { 439 | .heading { 440 | @apply font-bold text-4xl md:text-5xl text-center; 441 | } 442 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Mona_Sans } from "next/font/google"; 3 | import "./globals.css"; 4 | import { Toaster } from "sonner"; 5 | 6 | const monaSans = Mona_Sans({ 7 | variable: "--font-mona-sans", 8 | subsets: ["latin"], 9 | //display: "swap", // ⬅️ improves font performance 10 | }); 11 | 12 | 13 | 14 | export const metadata: Metadata = { 15 | title: "IntervueAI - Precision in Recruitment", 16 | description: "IntervueAI is an AI-driven interview platform with real-time voice integration and automated feedback, helping users prepare faster and smarter.", 17 | }; 18 | 19 | export default function RootLayout({ 20 | children, 21 | }: Readonly<{ 22 | children: React.ReactNode; 23 | }>) { 24 | return ( 25 | 26 | 29 | {children} 30 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/Agent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | import { vapi } from '@/lib/vapi.sdk'; 5 | 6 | 7 | import Image from 'next/image' 8 | import { useRouter } from 'next/navigation'; 9 | import React, { useEffect, useState } from 'react' 10 | import Loading from './Loading'; 11 | 12 | enum CallStatus { 13 | INACTIVE = 'INACTIVE', 14 | CONNECTING = 'CONNECTING', 15 | ACTIVE = 'ACTIVE', 16 | FINISHED = 'FINISHED', 17 | } 18 | 19 | interface SavedMessage { 20 | role: 'user' | 'system' | 'assistant'; 21 | content: string; 22 | } 23 | const Agent = ({userName, userId, type}: AgentProps) => { 24 | const router = useRouter(); 25 | const [isRedirecting, setIsRedirecting] = useState(false) 26 | const [isSpeaking, setIsSpeaking] = useState(false); 27 | const [callStatus, setCallStatus] = useState(CallStatus.INACTIVE) 28 | const [messages, setMessages] = useState([]); 29 | 30 | // const callStatus = CallStatus.ACTIVE; 31 | //const isSpeaking = true; 32 | //const messages = [ 33 | // 'Whats your name?', 34 | // 'My name is John Doe, nice to meet you!' 35 | //]; 36 | 37 | useEffect(() => { 38 | const onCallStart = () => setCallStatus(CallStatus.ACTIVE); 39 | const onCallEnd = () => setCallStatus(CallStatus.FINISHED); 40 | const onMessage = (message: Message) => { 41 | if(message.type === 'transcript' && message.transcriptType === 'final'){ 42 | const newMessage = { role: message.role, content: message.transcript} 43 | setMessages((prev) => [...prev,newMessage]); 44 | } 45 | } 46 | const onSpeechStart = () => setIsSpeaking(true); 47 | const onSpeechEnd = () => setIsSpeaking(false); 48 | const onError = (error: Error) => console.log('Error',error); 49 | vapi.on('call-start',onCallStart); 50 | vapi.on('call-end',onCallEnd); 51 | vapi.on('message',onMessage) 52 | vapi.on('speech-start',onSpeechStart); 53 | vapi.on('speech-end',onSpeechEnd); 54 | vapi.on('error',onError); 55 | 56 | return () => { 57 | vapi.off('call-start',onCallStart); 58 | vapi.off('call-end',onCallEnd); 59 | vapi.off('message',onMessage) 60 | vapi.off('speech-start',onSpeechStart); 61 | vapi.off('speech-end',onSpeechEnd); 62 | vapi.off('error',onError); 63 | } 64 | }, []) 65 | useEffect(() => { 66 | if(callStatus === CallStatus.FINISHED) { 67 | setIsRedirecting(true); // Trigger loader before navigation 68 | router.push('/dashboard'); 69 | } 70 | 71 | },[callStatus, router]); // ✅ include dependencies) 72 | 73 | const handleCall = async () => { 74 | setCallStatus(CallStatus.CONNECTING); 75 | await vapi.start(process.env.NEXT_PUBLIC_VAPI_WORKFLOW_ID!,{ 76 | variableValues:{ 77 | username: userName, 78 | userid:userId, 79 | } 80 | }) 81 | } 82 | const handleDisconnect = async () => { 83 | setCallStatus(CallStatus.FINISHED); 84 | vapi.stop(); 85 | } 86 | 87 | const latestMessage = messages[messages.length - 1]?.content; 88 | 89 | const isCallInactiveOrFinished = callStatus === CallStatus.INACTIVE || callStatus === CallStatus.FINISHED; 90 | 91 | return ( 92 | <> 93 |
94 | {isRedirecting && ( 95 |
96 | 97 |
98 | )} 99 |
100 |
connor{isSpeaking && }
101 |

Connor

102 |

Interview Architect 103 | 104 |

105 |
106 |
107 |
108 | user

{userName}

109 |
110 |
111 |
112 | 113 | {messages.length > 0 && ( 114 |
115 |
116 |

{latestMessage}

117 |
118 |
119 | )} 120 | 121 |
122 | {callStatus !== 'ACTIVE' ? ( 123 | 131 | ) : ( 132 | 135 | )} 136 | 137 |
138 | 139 | ) 140 | } 141 | 142 | export default Agent -------------------------------------------------------------------------------- /components/AuthForm.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { zodResolver } from "@hookform/resolvers/zod" 3 | import { useForm } from "react-hook-form" 4 | import { z } from "zod" 5 | import { useState } from "react" 6 | import { Button } from "@/components/ui/button" 7 | import { 8 | Form 9 | } from "@/components/ui/form" 10 | import Image from "next/image" 11 | import Link from "next/link" 12 | import { toast } from "sonner" 13 | import FormField from "./FormField" 14 | import { useRouter } from "next/navigation" 15 | import { createUserWithEmailAndPassword, signInWithEmailAndPassword } from "firebase/auth"; 16 | import { auth } from "@/firebass/client"; 17 | import { signIn, signUp } from "@/lib/actions/auth.action"; 18 | import Loading from "./Loading" 19 | 20 | 21 | const AuthFormSchema = (type: FormType) => {return z.object ({ 22 | name: type === 'sign-up' ? z.string().min(3) : z.string().optional(), 23 | email: z.string().email(), 24 | password: z.string().min(6), 25 | })} 26 | 27 | 28 | const AuthForm = ({type}: {type: FormType}) => { 29 | const router = useRouter(); 30 | const formSchema = AuthFormSchema(type); 31 | // Add loading state 32 | const [isLoading, setIsLoading] = useState(false); 33 | 34 | // 1. Define your form. 35 | const form = useForm>({ 36 | resolver: zodResolver(formSchema), 37 | defaultValues: { 38 | name: "", 39 | email: "", 40 | password:"", 41 | }, 42 | }) 43 | 44 | // 2. Define a submit handler. 45 | async function onSubmit(values: z.infer) { 46 | // Set loading to true when form is submitted 47 | setIsLoading(true); 48 | 49 | try { 50 | if(type === 'sign-up') { 51 | const { name, email, password } = values; 52 | const userCredentials = await createUserWithEmailAndPassword(auth,email,password) 53 | const result = await signUp({ 54 | uid: userCredentials.user.uid, 55 | name: name!, 56 | email, 57 | password, 58 | }) 59 | if (!result?.success){ 60 | toast.error(result?.message); 61 | setIsLoading(false); 62 | return; 63 | } 64 | 65 | toast.success("Account created successfully.") 66 | router.push('/dashboard') 67 | } else { 68 | const {email,password} = values; 69 | const userCredential = await signInWithEmailAndPassword(auth,email,password); 70 | const idToken = await userCredential.user.getIdToken(); 71 | if(!idToken) { 72 | toast.error('Sign in failed') 73 | setIsLoading(false); 74 | return; 75 | } 76 | await signIn({ email, idToken}) 77 | toast.success("Sign in successfully."); 78 | router.push('/dashboard') 79 | } 80 | } catch(error){ 81 | console.log(error); 82 | toast.error(`There is an error: ${error}`); 83 | // Set loading to false on error 84 | setIsLoading(false); 85 | } 86 | } 87 | 88 | const isSignIn = type === 'sign-in'; 89 | 90 | return ( 91 |
92 | {/* Full screen loader overlay */} 93 | {isLoading && ( 94 |
{isSignIn ? : }
95 | )} 96 | 97 |
98 |
99 | logo

IntervueAI

100 |
101 |
Practice Like You Truly Mean It
102 | 103 |
104 | 105 | 106 | {!isSignIn && ( 111 | )} 112 | 119 | 126 | 127 | 130 | 131 | 158 | 159 | 160 | 161 |

162 | {isSignIn ? 'No account yet?': 'Have an account already?'} 163 | 164 | 165 | {!isSignIn ? "Sign in" : 'Sign up'} 166 |

167 |
168 |
169 | ) 170 | } 171 | 172 | export default AuthForm -------------------------------------------------------------------------------- /components/BackToDashboardButton.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { useRouter } from "next/navigation" 5 | import { Button } from "@/components/ui/button" 6 | import Loading from "@/components/Loading" 7 | 8 | const BackToDashboardButton = () => { 9 | const [loading, setLoading] = useState(false) 10 | const router = useRouter() 11 | 12 | const handleClick = () => { 13 | setLoading(true) 14 | router.push("/dashboard") 15 | } 16 | 17 | return ( 18 | 24 | ) 25 | } 26 | 27 | export default BackToDashboardButton 28 | -------------------------------------------------------------------------------- /components/CheckFeedbackInterviewButton.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { useRouter } from "next/navigation" 5 | import { Button } from "./ui/button" 6 | import Loading from "./Loading" 7 | 8 | interface Props { 9 | hasFeedback: boolean 10 | interviewId: string 11 | } 12 | 13 | const CheckFeedbackInterviewButton = ({ hasFeedback, interviewId }: Props) => { 14 | const [loading, setLoading] = useState(false) 15 | const router = useRouter() 16 | 17 | const handleClick = () => { 18 | setLoading(true) 19 | const url = hasFeedback 20 | ? `/interview/${interviewId}/feedback` 21 | : `/interview/${interviewId}` 22 | router.push(url) 23 | } 24 | 25 | return ( 26 | 30 | ) 31 | } 32 | 33 | export default CheckFeedbackInterviewButton 34 | -------------------------------------------------------------------------------- /components/DisplayTechicons.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { getTechLogos } from '@/lib/utils'; 3 | import Image from 'next/image'; 4 | import React from 'react' 5 | 6 | const DisplayTechicons = async ({techStack}: TechIconProps) => { 7 | const techIcons = await getTechLogos(techStack) 8 | return ( 9 |
{techIcons.slice(0,3).map (({tech, url}, index)=> 10 | 11 |
12 | {tech} 13 | {tech} 14 |
15 | )}
16 | ) 17 | } 18 | 19 | export default DisplayTechicons; -------------------------------------------------------------------------------- /components/Features.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import {features} from '@/constants/feature' 4 | const Features = () => { 5 | return ( 6 | 7 | {features.map((feature, index) => ( 8 |
9 |
10 |
11 |
12 |
{feature.title}
13 |
{feature.description}
14 |
15 |
16 |
17 |
18 |
19 |
20 | ))} 21 | 22 | ); 23 | } 24 | 25 | const StyledWrapper = styled.div` 26 | display: flex; 27 | flex-direction: column; /* Stack items vertically */ 28 | gap: 2rem; /* spacing between cards */ 29 | align-items: center; 30 | @media (min-width: 640px) { 31 | flex-direction: row; /* Row layout on sm and up */ 32 | flex-wrap: wrap; 33 | justify-content: center; 34 | } 35 | .outer { 36 | width: 300px; 37 | height: 250px; 38 | border-radius: 10px; 39 | padding: 1px; 40 | background: radial-gradient(circle 230px at 0% 0%, #ffffff, #9F79C1); 41 | position: relative; 42 | 43 | 44 | } 45 | 46 | .dot { 47 | width: 5px; 48 | aspect-ratio: 1; 49 | position: absolute; 50 | background-color: #fff; 51 | box-shadow: 0 0 10px #ffffff; 52 | border-radius: 100px; 53 | z-index: 2; 54 | right: 10%; 55 | top: 10%; 56 | animation: moveDot 6s linear infinite; 57 | } 58 | 59 | @keyframes moveDot { 60 | 0%, 61 | 100% { 62 | top: 10%; 63 | right: 10%; 64 | } 65 | 25% { 66 | top: 10%; 67 | right: calc(100% - 35px); 68 | } 69 | 50% { 70 | top: calc(100% - 30px); 71 | right: calc(100% - 35px); 72 | } 73 | 75% { 74 | top: calc(100% - 30px); 75 | right: 10%; 76 | } 77 | } 78 | 79 | .card { 80 | z-index: 1; 81 | width: 100%; 82 | height: 100%; 83 | border-radius: 9px; 84 | border: solid 1px #202222; 85 | background-size: 20px 20px; 86 | background: radial-gradient(circle 280px at 0% 0%, #444444, #0c0d0d); 87 | display: flex; 88 | align-items: center; 89 | justify-content: center; 90 | position: relative; 91 | flex-direction: column; 92 | color: #fff; 93 | } 94 | .ray { 95 | width: 220px; 96 | height: 45px; 97 | border-radius: 100px; 98 | position: absolute; 99 | background-color: #c7c7c7; 100 | opacity: 0.4; 101 | box-shadow: 0 0 50px #fff; 102 | filter: blur(10px); 103 | transform-origin: 10%; 104 | top: 0%; 105 | left: 0; 106 | transform: rotate(40deg); 107 | } 108 | 109 | .card .text { 110 | font-weight: bolder; 111 | font-size: 4rem; 112 | background: linear-gradient(45deg, #000000 4%, #fff, #000); 113 | background-clip: text; 114 | color: transparent; 115 | } 116 | 117 | .line { 118 | width: 100%; 119 | height: 1px; 120 | position: absolute; 121 | background-color: #2c2c2c; 122 | } 123 | .topl { 124 | top: 10%; 125 | background: linear-gradient(90deg, #888888 30%, #1d1f1f 70%); 126 | } 127 | .bottoml { 128 | bottom: 10%; 129 | } 130 | .leftl { 131 | left: 10%; 132 | width: 1px; 133 | height: 100%; 134 | background: linear-gradient(180deg, #747474 30%, #222424 70%); 135 | } 136 | .rightl { 137 | right: 10%; 138 | width: 1px; 139 | height: 100%; 140 | }`; 141 | 142 | export default Features -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | import Github from './Github'; 5 | // Remove if not using react-router 6 | import { useEffect, useState } from 'react'; 7 | const Footer = () => { 8 | const [year, setYear] = useState(null); 9 | 10 | useEffect(() => { 11 | setYear(new Date().getFullYear()); 12 | }, []); 13 | return ( 14 |
15 | {/* Curved border effect */} 16 | 17 | 18 | {/* Logo and tagline */} 19 |
20 | Logo 21 | 22 | < Github /> 23 | 24 |

25 | Your Gateway to Smarter Interview Preparation! 26 |

27 |
28 | 29 | 30 | {/* Divider */} 31 |
32 | 33 | {/* Bottom row */} 34 |
35 |
36 | LinkedIn 37 | GitHub 38 | Contact Us 39 |
40 |
41 | {year && <>Copyright © {year} | IntervueAI} 42 |
43 |
44 |
45 | ); 46 | }; 47 | 48 | export default Footer; 49 | -------------------------------------------------------------------------------- /components/FormField.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; 3 | import {Input} from "@/components/ui/input"; 4 | import { Controller, FieldValues } from "react-hook-form"; 5 | 6 | interface FormFieldProps { 7 | control: Control; 8 | name: Path; 9 | label: string; 10 | placeholder?: string; 11 | type?:'text' | 'email' | 'password' | 'file' 12 | } 13 | 14 | const FormField = ({control,name,label,placeholder, type="text"}: FormFieldProps) => ( 15 | ( 19 | 20 | {label} 21 | 22 | 23 | 24 | 25 | 26 | )} 27 | /> 28 | ) 29 | 30 | export default FormField -------------------------------------------------------------------------------- /components/GetInterview.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { interviewer } from '@/constants'; 3 | import { createFeedback } from '@/lib/actions/generate.action'; 4 | import { cn } from '@/lib/utils'; 5 | import { vapi } from '@/lib/vapi.sdk'; 6 | import Image from 'next/image' 7 | import { useRouter } from 'next/navigation'; 8 | import React, { useEffect, useState } from 'react' 9 | import Loading from './Loading'; 10 | 11 | enum CallStatus { 12 | INACTIVE = 'INACTIVE', 13 | CONNECTING = 'CONNECTING', 14 | ACTIVE = 'ACTIVE', 15 | FINISHED = 'FINISHED', 16 | } 17 | 18 | interface SavedMessage { 19 | role: 'user' | 'system' | 'assistant'; 20 | content: string; 21 | } 22 | const GetInterview = ({userName, userId, type, interviewId, questions}: GetInterviewProps) => { 23 | const router = useRouter(); 24 | const [isRedirecting, setIsRedirecting] = useState(false) 25 | const [isSpeaking, setIsSpeaking] = useState(false); 26 | const [callStatus, setCallStatus] = useState(CallStatus.INACTIVE) 27 | const [messages, setMessages] = useState([]); 28 | 29 | // const callStatus = CallStatus.ACTIVE; 30 | //const isSpeaking = true; 31 | //const messages = [ 32 | // 'Whats your name?', 33 | // 'My name is John Doe, nice to meet you!' 34 | //]; 35 | 36 | useEffect(() => { 37 | const onCallStart = () => setCallStatus(CallStatus.ACTIVE); 38 | const onCallEnd = () => setCallStatus(CallStatus.FINISHED); 39 | const onMessage = (message: Message) => { 40 | if(message.type === 'transcript' && message.transcriptType === 'final'){ 41 | const newMessage = { role: message.role, content: message.transcript} 42 | setMessages((prev) => [...prev,newMessage]); 43 | } 44 | } 45 | const onSpeechStart = () => setIsSpeaking(true); 46 | const onSpeechEnd = () => setIsSpeaking(false); 47 | const onError = (error: Error) => console.log('Error',error); 48 | vapi.on('call-start',onCallStart); 49 | vapi.on('call-end',onCallEnd); 50 | vapi.on('message',onMessage) 51 | vapi.on('speech-start',onSpeechStart); 52 | vapi.on('speech-end',onSpeechEnd); 53 | vapi.on('error',onError); 54 | 55 | return () => { 56 | vapi.off('call-start',onCallStart); 57 | vapi.off('call-end',onCallEnd); 58 | vapi.off('message',onMessage) 59 | vapi.off('speech-start',onSpeechStart); 60 | vapi.off('speech-end',onSpeechEnd); 61 | vapi.off('error',onError); 62 | } 63 | }, []) 64 | const handleGenerateFeedback = async (messages: SavedMessage[] ) => { 65 | console.log('Generate feedback here.'); 66 | const { success, feedbackId: id } = await createFeedback({ 67 | interviewId: interviewId!, 68 | userId: userId!, 69 | transcript: messages, 70 | 71 | }); 72 | 73 | 74 | if (success && id ) { 75 | router.push(`/interview/${interviewId}/feedback`) 76 | setIsRedirecting(true) // Start loading before route change 77 | } else { 78 | console.log('Error saving feedback') 79 | router.push('/dashboard'); 80 | } 81 | } 82 | useEffect(() => { 83 | if(callStatus === CallStatus.FINISHED) { 84 | if(type === 'generate'){ 85 | router.push('/') 86 | } else { 87 | handleGenerateFeedback(messages); 88 | } 89 | }; 90 | }) 91 | 92 | const handleCall = async () => { 93 | setCallStatus(CallStatus.CONNECTING); 94 | 95 | if (type === "generate") { 96 | await vapi.start(process.env.NEXT_PUBLIC_VAPI_WORKFLOW_ID!, { 97 | variableValues: { 98 | username: userName, 99 | userid: userId, 100 | }, 101 | }); 102 | } else { 103 | let formattedQuestions = ""; 104 | if (questions) { 105 | formattedQuestions = questions 106 | .map((question) => `- ${question}`) 107 | .join("\n"); 108 | } 109 | 110 | await vapi.start(interviewer, { 111 | variableValues: { 112 | questions: formattedQuestions, 113 | }, 114 | }); 115 | } 116 | }; 117 | const handleDisconnect = async () => { 118 | setCallStatus(CallStatus.FINISHED); 119 | vapi.stop(); 120 | } 121 | 122 | const latestMessage = messages[messages.length - 1]?.content; 123 | 124 | const isCallInactiveOrFinished = callStatus === CallStatus.INACTIVE || callStatus === CallStatus.FINISHED; 125 | 126 | return ( 127 | <> 128 |
129 | {isRedirecting && ( 130 |
131 | 132 |
133 | )} 134 |
135 |
connor{isSpeaking && }
136 |

Chloe

137 |

Virtual Interviewer

138 |
139 |
140 |
141 | user

{userName}

142 |
143 |
144 |
145 | 146 | {messages.length > 0 && ( 147 |
148 |
149 |

{latestMessage}

150 |
151 |
152 | )} 153 | 154 |
155 | {callStatus !== 'ACTIVE' ? ( 156 | 164 | ) : ( 165 | 168 | )} 169 | 170 |
171 | 172 | ) 173 | } 174 | 175 | export default GetInterview -------------------------------------------------------------------------------- /components/GetStartedbtn.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components'; 3 | const GetStartedbtn = () => { 4 | return ( 5 | 6 | 8 | 9 | ); 10 | } 11 | 12 | const StyledWrapper = styled.div` 13 | button { 14 | --glow-color: rgb(217, 176, 255); 15 | --glow-spread-color: rgba(191, 123, 255, 0.781); 16 | --enhanced-glow-color: rgb(231, 206, 255); 17 | --btn-color: rgb(100, 61, 136); 18 | border: .25em solid var(--glow-color); 19 | padding: 1em 3em; 20 | color: var(--glow-color); 21 | font-size: 15px; 22 | font-weight: bold; 23 | background-color: var(--btn-color); 24 | border-radius: 1em; 25 | outline: none; 26 | box-shadow: 0 0 1em .25em var(--glow-color), 27 | 0 0 4em 1em var(--glow-spread-color), 28 | inset 0 0 .75em .25em var(--glow-color); 29 | text-shadow: 0 0 .5em var(--glow-color); 30 | position: relative; 31 | transition: all 0.3s; 32 | } 33 | 34 | button::after { 35 | pointer-events: none; 36 | content: ""; 37 | position: absolute; 38 | top: 120%; 39 | left: 0; 40 | height: 100%; 41 | width: 100%; 42 | background-color: var(--glow-spread-color); 43 | filter: blur(2em); 44 | opacity: .7; 45 | transform: perspective(1.5em) rotateX(35deg) scale(1, .6); 46 | } 47 | 48 | button:hover { 49 | color: var(--btn-color); 50 | background-color: var(--glow-color); 51 | box-shadow: 0 0 1em .25em var(--glow-color), 52 | 0 0 4em 2em var(--glow-spread-color), 53 | inset 0 0 .75em .25em var(--glow-color); 54 | } 55 | 56 | button:active { 57 | box-shadow: 0 0 0.6em .25em var(--glow-color), 58 | 0 0 2.5em 2em var(--glow-spread-color), 59 | inset 0 0 .5em .25em var(--glow-color); 60 | }`; 61 | 62 | export default GetStartedbtn -------------------------------------------------------------------------------- /components/Github.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | 4 | const Github = () => { 5 | return ( 6 | 19 | ); 20 | } 21 | 22 | export default Github -------------------------------------------------------------------------------- /components/HomeLoginbtn.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React from 'react' 3 | 4 | import styled from 'styled-components'; 5 | const HomeLoginbtn = () => { 6 | return ( 7 | 8 | 12 | 13 | ); 14 | } 15 | 16 | const StyledWrapper = styled.div` 17 | button { 18 | position: relative; 19 | padding: 4px 22px; 20 | background: transparent; 21 | font-size: 17px; 22 | font-weight: 500; 23 | color:#DDDFFF ; 24 | border: 2px solid #6F75B3; 25 | border-radius: 8px; 26 | box-shadow: 0 0 1em .25em var(--glow-color), 27 | 0 0 4em 1em var(--glow-spread-color), 28 | inset 0 0 .75em .25em var(--glow-color); 29 | text-shadow: 0 0 .5em var(--glow-color);; 30 | transition: all 0.3s ease-in-out; 31 | cursor: pointer; 32 | } 33 | 34 | 35 | 36 | button:hover { 37 | background: transparent; 38 | color: #A998DA; 39 | 40 | box-shadow:0 0 1em .25em var(--glow-color), 41 | 0 0 4em 2em var(--glow-spread-color), 42 | inset 0 0 .75em .25em var(--glow-color); ; 43 | } 44 | 45 | 46 | .fil0 { 47 | fill: 0 0 1em .25em var(--glow-color), 48 | 0 0 4em 2em var(--glow-spread-color), 49 | inset 0 0 .75em .25em var(--glow-color);; 50 | }`; 51 | 52 | 53 | 54 | export default HomeLoginbtn -------------------------------------------------------------------------------- /components/HomePlay.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Image from 'next/image'; 3 | import React, { useState } from 'react'; 4 | import { useEffect, useRef } from "react"; 5 | const HomePlay = () => { 6 | const [isHovered, setIsHovered] = useState(false); 7 | const [showVideo, setShowVideo] = useState(false); 8 | 9 | const toggleVideo = () => { 10 | setShowVideo(!showVideo); 11 | }; 12 | const imageRef = useRef(null); 13 | 14 | useEffect(() => { 15 | const imageElement = imageRef.current; 16 | const handleScroll = () => { 17 | const scrollPosition = window.scrollY; 18 | const scrollThreshold = 100; // AFTER HOW MUCH SCROLL 19 | 20 | if (scrollPosition > scrollThreshold) { 21 | imageElement.classList.add("scrolled"); 22 | } else { 23 | imageElement.classList.remove("scrolled"); 24 | } 25 | }; 26 | window.addEventListener("scroll", handleScroll); 27 | 28 | return () => window.removeEventListener("scroll", handleScroll); 29 | }, []); 30 | 31 | return ( 32 | <> 33 | 34 |
35 | {/* Video frame container */} 36 | {/* ✅ Play Button Overlayed 37 | 57 | */} 58 | 59 | {/* Background Image */} 60 |
61 |
62 | banner 70 |
71 |
72 | 92 | 93 | 94 | 95 | {/* Video Modal */} 96 | {showVideo && ( 97 |
98 | {/* Backdrop */} 99 |
107 | 108 | {/* Video Container */} 109 |
110 |
111 | 119 |
120 |
121 |
122 | )} 123 | 124 |
125 | 126 | 127 | ); 128 | }; 129 | 130 | export default HomePlay; 131 | -------------------------------------------------------------------------------- /components/InterviewCard.tsx: -------------------------------------------------------------------------------- 1 | import { getRandomInterviewCover } from "@/lib/utils"; 2 | import dayjs from "dayjs"; 3 | import Image from "next/image"; 4 | 5 | import DisplayTechicons from "./DisplayTechicons"; 6 | import { getFeedbackByInterviewId } from "@/lib/actions/generate.action"; 7 | import CheckFeedbackInterviewButton from "./CheckFeedbackInterviewButton"; 8 | 9 | const InterviewCard = async ({ id, userId, role, type, techstack, createdAt}: InterviewCardProps) => { 10 | const feedback = userId && id ? await getFeedbackByInterviewId({interviewId : id, userId}) : null; 11 | const normalizedType = /mix/gi.test(type) ? 'Mixed' : type; 12 | 13 | const formattedDate = dayjs(feedback?.createdAt || createdAt || Date.now()).format('MMM D, YYYY'); 14 | return ( 15 |
16 | 17 |
18 | 19 |
20 | 21 |

22 | {normalizedType}

23 |
24 | 25 | cover 26 | 27 |

{role} Interview

28 | 29 |
30 | 31 |
32 | 33 | calender 34 |

{formattedDate}

35 | 36 |
star 37 |

{feedback?.totalScore || '---'}/100

38 |
39 |
40 |
41 |

{feedback?.finalAssessment || "You haven't taken interview yet. Take it now to improve your skills." }

42 | 43 |
44 | 45 | 46 | 47 |
48 |
49 |
50 | 51 | ) 52 | } 53 | 54 | export default InterviewCard; -------------------------------------------------------------------------------- /components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Loader = () => { 5 | return ( 6 | 7 |
8 |
9 |
10 | 11 | ); 12 | } 13 | 14 | const StyledWrapper = styled.div` 15 | .spinner { 16 | background-image: linear-gradient(rgb(186, 66, 255) 35%,rgb(0, 225, 255)); 17 | width: 100px; 18 | height: 100px; 19 | animation: spinning82341 1.7s linear infinite; 20 | text-align: center; 21 | border-radius: 50px; 22 | filter: blur(1px); 23 | box-shadow: 0px -5px 20px 0px rgb(186, 66, 255), 0px 5px 20px 0px rgb(0, 225, 255); 24 | } 25 | 26 | .spinner1 { 27 | background-color: rgb(36, 36, 36); 28 | width: 100px; 29 | height: 100px; 30 | border-radius: 50px; 31 | filter: blur(10px); 32 | } 33 | 34 | @keyframes spinning82341 { 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | }`; 39 | 40 | export default Loader; 41 | -------------------------------------------------------------------------------- /components/Loading.tsx: -------------------------------------------------------------------------------- 1 | // app/loading.tsx 2 | import Loader from '@/components/Loader'; 3 | 4 | export default function Loading() { 5 | return ( 6 |
7 |
8 |
9 | 10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /components/RetakeInterviewButton.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { useRouter } from "next/navigation" 5 | import { Button } from "@/components/ui/button" 6 | import Loading from "@/components/Loading" 7 | 8 | interface Props { 9 | interviewId: string 10 | } 11 | 12 | const RetakeInterviewButton = ({ interviewId }: Props) => { 13 | const [loading, setLoading] = useState(false) 14 | const router = useRouter() 15 | 16 | const handleClick = () => { 17 | setLoading(true) 18 | router.push(`/interview/${interviewId}`) 19 | } 20 | 21 | return ( 22 | 28 | ) 29 | } 30 | 31 | export default RetakeInterviewButton 32 | -------------------------------------------------------------------------------- /components/SignOutButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { signOut } from '@/lib/actions/auth.action' 4 | import Image from 'next/image'; 5 | 6 | 7 | const SignOutButton = () => { 8 | const handleSignOut = () => { 9 | signOut() 10 | } 11 | 12 | return ( 13 | <> 14 | 18 | 19 | ); 20 | } 21 | 22 | export default SignOutButton 23 | -------------------------------------------------------------------------------- /components/StartInterviewButton.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { Button } from "./ui/button" 5 | import Loading from "./Loading" 6 | import { useRouter } from "next/navigation" 7 | 8 | const StartInterviewButton = () => { 9 | const [loading, setLoading] = useState(false) 10 | const router = useRouter() 11 | 12 | const handleClick = () => { 13 | setLoading(true) 14 | router.push("/interview") 15 | } 16 | 17 | return ( 18 | 22 | ) 23 | } 24 | 25 | export default StartInterviewButton 26 | -------------------------------------------------------------------------------- /components/Testimonials.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import { testimonials } from "@/constants/testimonials"; 6 | import { InfiniteMovingCards } from "@/components/ui/InfiniteCards"; 7 | 8 | 9 | const Testimonials = () => { 10 | return ( 11 |
12 |

13 | Kind words from 14 | satisfied Users 15 |

16 | 17 |
18 |
22 | 27 |
28 | 29 |
30 |
31 | ); 32 | } 33 | 34 | export default Testimonials -------------------------------------------------------------------------------- /components/ui/InfiniteCards.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import Image from "next/image"; 5 | import React, { useEffect, useState } from "react"; 6 | 7 | export const InfiniteMovingCards = ({ 8 | items, 9 | direction = "left", 10 | speed = "fast", 11 | pauseOnHover = true, 12 | className, 13 | }: { 14 | items: { 15 | quote: string; 16 | name: string; 17 | title: string; 18 | }[]; 19 | direction?: "left" | "right"; 20 | speed?: "fast" | "normal" | "slow"; 21 | pauseOnHover?: boolean; 22 | className?: string; 23 | }) => { 24 | const containerRef = React.useRef(null); 25 | const scrollerRef = React.useRef(null); 26 | 27 | useEffect(() => { 28 | addAnimation(); 29 | }, []); 30 | const [start, setStart] = useState(false); 31 | function addAnimation() { 32 | if (containerRef.current && scrollerRef.current) { 33 | const scrollerContent = Array.from(scrollerRef.current.children); 34 | 35 | scrollerContent.forEach((item) => { 36 | const duplicatedItem = item.cloneNode(true); 37 | if (scrollerRef.current) { 38 | scrollerRef.current.appendChild(duplicatedItem); 39 | } 40 | }); 41 | 42 | getDirection(); 43 | getSpeed(); 44 | setStart(true); 45 | } 46 | } 47 | const getDirection = () => { 48 | if (containerRef.current) { 49 | if (direction === "left") { 50 | containerRef.current.style.setProperty( 51 | "--animation-direction", 52 | "forwards" 53 | ); 54 | } else { 55 | containerRef.current.style.setProperty( 56 | "--animation-direction", 57 | "reverse" 58 | ); 59 | } 60 | } 61 | }; 62 | const getSpeed = () => { 63 | if (containerRef.current) { 64 | if (speed === "fast") { 65 | containerRef.current.style.setProperty("--animation-duration", "20s"); 66 | } else if (speed === "normal") { 67 | containerRef.current.style.setProperty("--animation-duration", "40s"); 68 | } else { 69 | containerRef.current.style.setProperty("--animation-duration", "80s"); 70 | } 71 | } 72 | }; 73 | return ( 74 |
82 |
    90 | {items.map((item, idx) => ( 91 |
  • 107 |
    108 | 112 | {/* change text color, text-lg */} 113 | 114 | {item.quote.replace(/^•\s*/, "")} 115 | 116 |
    117 | {/* add this div for the profile img */} 118 |
    119 | {`profile-${idx 125 | 126 |
    127 | 128 | {/* change text color, font-normal to font-bold, text-xl */} 129 | 130 | {item.name} 131 | 132 | {/* change text color */} 133 | 134 | {item.title} 135 | 136 | 137 |
    138 |
    139 |
  • 140 | ))} 141 |
142 |
143 | ); 144 | }; -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { Slot } from "@radix-ui/react-slot" 6 | import { 7 | Controller, 8 | FormProvider, 9 | useFormContext, 10 | useFormState, 11 | type ControllerProps, 12 | type FieldPath, 13 | type FieldValues, 14 | } from "react-hook-form" 15 | 16 | import { cn } from "@/lib/utils" 17 | import { Label } from "@/components/ui/label" 18 | 19 | const Form = FormProvider 20 | 21 | type FormFieldContextValue< 22 | TFieldValues extends FieldValues = FieldValues, 23 | TName extends FieldPath = FieldPath, 24 | > = { 25 | name: TName 26 | } 27 | 28 | const FormFieldContext = React.createContext( 29 | {} as FormFieldContextValue 30 | ) 31 | 32 | const FormField = < 33 | TFieldValues extends FieldValues = FieldValues, 34 | TName extends FieldPath = FieldPath, 35 | >({ 36 | ...props 37 | }: ControllerProps) => { 38 | return ( 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | const useFormField = () => { 46 | const fieldContext = React.useContext(FormFieldContext) 47 | const itemContext = React.useContext(FormItemContext) 48 | const { getFieldState } = useFormContext() 49 | const formState = useFormState({ name: fieldContext.name }) 50 | const fieldState = getFieldState(fieldContext.name, formState) 51 | 52 | if (!fieldContext) { 53 | throw new Error("useFormField should be used within ") 54 | } 55 | 56 | const { id } = itemContext 57 | 58 | return { 59 | id, 60 | name: fieldContext.name, 61 | formItemId: `${id}-form-item`, 62 | formDescriptionId: `${id}-form-item-description`, 63 | formMessageId: `${id}-form-item-message`, 64 | ...fieldState, 65 | } 66 | } 67 | 68 | type FormItemContextValue = { 69 | id: string 70 | } 71 | 72 | const FormItemContext = React.createContext( 73 | {} as FormItemContextValue 74 | ) 75 | 76 | function FormItem({ className, ...props }: React.ComponentProps<"div">) { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
86 | 87 | ) 88 | } 89 | 90 | function FormLabel({ 91 | className, 92 | ...props 93 | }: React.ComponentProps) { 94 | const { error, formItemId } = useFormField() 95 | 96 | return ( 97 |