Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                

DEV Community

Cover image for Build a Job Application and Interview App with NextJS, Stream & Firebase
David Asaolu for Stream

Posted on • Originally published at getstream.io

Build a Job Application and Interview App with NextJS, Stream & Firebase

Hiring the right candidate and finding the perfect job can be challenging. Recruiters need an efficient way to manage job postings, screen applicants, and conduct virtual interviewsβ€”all in one platform.

In this tutorial, you will learn how to build a job application and interviewing platform using Next.js, Stream, and Firebase. This app will allow recruiters to post job openings, review applications, and schedule interviews. Job seekers can also apply for jobs and communicate with recruiters.

The Stream Video & Audio API will enable recruiters to schedule and conduct virtual interviews, and the Stream Chat SDK will allow them to chat with shortlisted candidates.

Job Application

App Overview

The application has two types of users: job seekers and recruiters.

Job seekers can:

  • Have a public profile URL and can upload a downloadable resume.
  • See a job feed tailored to their selected interests upon signing in.
  • Apply for jobs, chat with recruiters, and join online interviews within the application.
  • Track their progress on every job application.

Recruiters can:

  • Create job postings.
  • Manage applications by accepting or rejecting candidates.
  • Chat privately with shortlisted applicants.
  • Schedule online interviews and hire the best candidate.

Application Workflow

Here is the application demo:


Prerequisites

To fully understand this tutorial, you need to have a basic understanding of React or Next.js.

We will use the following tools:

  • Firebase - a Backend-as-a-service platform developed by Google to enable us to add authentication, database, real-time communication, file storage, cloud functions, and many others within software applications.
  • Stream React Chat SDK and React Video SDK - SDKs that enable real-time chat and video/audio communication in your application.
  • Shadcn UI: a UI component library that provides customizable, beautifully designed, and accessible UI components for your applications.

Create a Next.js project by running the following code snippet:

npx create-next-app job-interview-app
Enter fullscreen mode Exit fullscreen mode

Install the package dependencies for the project:

npm install firebase @stream-io/node-sdk @stream-io/video-react-sdk stream-chat stream-chat-react 
Enter fullscreen mode Exit fullscreen mode

To install the Shadcn UI library, follow the installation guide.

Once everything is set up, your Next.js project is ready.
Now, let's start building! πŸš€


How to Set up Firebase in a Next.js Application

Firebase is a cloud platform that enables you to build full-stack software applications without worrying about managing your database or server infrastructure. It provides features like authentication, real-time database, cloud functions, file storage, and more.

In this section, you'll learn how to install Firebase in a Next.js application and configure the Firestore Database, Firebase storage and authentication within your Next.js application.

Setting up Firebase in a Next.js Application

Install the Firebase Node.js package by running the code snippet below:

npm install firebase
Enter fullscreen mode Exit fullscreen mode

Open the Firebase Console in your browser and create a new Firebase project.

Create Firebase Project

Within the project dashboard, click the web icon </> to add a Firebase app to the project.

Create Firebase app

Register the app by entering a nickname, then copy the auto-generated Firebase configuration code. You will need this code to connect your application to the Firebase backend.

Firebase configuration code

Create a lib/firebase.ts file within the Next.js src folder and paste the following code snippet into the file:

import { initializeApp, getApps } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { EmailAuthProvider } from "firebase/auth";
import { getAuth } from "firebase/auth";
import { getStorage } from "firebase/storage";

const firebaseConfig = {
    // πŸ‘‰πŸ» your Firebase app configuration code
};

//πŸ‘‡πŸ» Initialize Firebase
const app =
    getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
const provider = new EmailAuthProvider();
const storage = getStorage(app);
const db = getFirestore(app);
const auth = getAuth(app);

//πŸ‘‡πŸ» exports each variable for use within the application
export { provider, auth, storage };
export default db;
Enter fullscreen mode Exit fullscreen mode

The code snippet above initializes Firebase Storage, Firestore, and the Email Authentication provider. This setup allows you to add file storage, interact with a database, and implement email/password authentication within the application.

Before interacting with Firebase features, you must set them up in your project dashboard.

Click Build in the sidebar navigation of your dashboard. This will open a dropdown menu with various features you can enable for your project.

Select Firebase Build

Select Authentication, Firestore Database, and Storage from the drop-down and add them to the project.

Congratulations! You can now start interacting with these Firebase features in your Next.js project.

Authenticating Job Seekers

In this section, you’ll learn about the necessary attributes for job seekers and how they can create an account, sign in, and sign out of the application.

Before we proceed, create a types.d.ts file at the root of your Next.js project and copy the following code into it:

//πŸ‘‡πŸ» before Firebase upload
interface JobSeeker {
    name: string;
    email: string;
    password: string;
    image: File;
    bio: string;
    cv: File;
    fieldOfInterest: "tech" | "consulting" | "finance" | "healthcare";
    portfolioUrl: string;
}
//πŸ‘‡πŸ» after Firebase upload
interface JobSeekerFirebase {
    name: string;
    email: string;
    image: string;
    bio: string;
    cv: string;
    id: string;
    fieldOfInterest: "tech" | "consulting" | "finance" | "healthcare";
    portfolioUrl: string;
}
Enter fullscreen mode Exit fullscreen mode

The code snippet above defines the data structure for a job seeker. The JobSeeker interface shows that job seekers provide their name, email, password, short bio, field of interest, and portfolio URL when signing up. They must also upload their CV/Resume and a professional headshot image.

In the JobSeekerFirebase structure, the CV and image fields are represented as strings because, after uploading the files, we'll retrieve their Firebase storage URLs.

Job Seeker Registration

Create a lib/auth-functions.ts file. It will contain the Firebase authentication functions for both job seekers and recruiters, so you can easily import them into any component where these functions are needed.

mkdir lib && cd lib && \
touch auth-functions.ts
Enter fullscreen mode Exit fullscreen mode

Next, add the signup function to the file:

import {
    createUserWithEmailAndPassword,
    signInWithEmailAndPassword,
} from "firebase/auth";
import { getDownloadURL, ref, uploadBytes } from "@firebase/storage";
import db, { auth, storage } from "./firebase";
import { doc, setDoc, getDoc } from "firebase/firestore";

export const jobSeekerSignUp = async (form: FormData) => {
    //πŸ‘‡πŸ» Things to do here:
    // 1. get the form data
    // 2. create a user using the email and password
    // 3. upload the CV and headshot to Firebase storage
    // 4. create a Firestore collection using the jobseeker data
    // 5. return errors at each stage.
}
Enter fullscreen mode Exit fullscreen mode

Modify the function to create a new user in Firebase.

export const jobSeekerSignUp = async (form: FormData) => {
    const userData: JobSeeker = {
        name: form.get("name") as string,
        email: form.get("email") as string,
        bio: form.get("bio") as string,
        password: form.get("password") as string,
        fieldOfInterest: form.get("field") as JobSeeker["fieldOfInterest"],
        image: form.get("image") as File,
        cv: form.get("cv") as File,
        portfolioUrl: form.get("url") as string,
 };
    //πŸ‘‡πŸ» creates a Firebase user using the email and password
    const { user } = await createUserWithEmailAndPassword(
        auth,
        userData.email,
        userData.password
 );
    //πŸ‘‡πŸ» returns an error message
    if (!user) {
        return {
            code: "auth/failed",
            status: 500,
            user: null,
            message: "Failed to create user",
 };
 }

    // 3. upload the CV and headshot to Firebase storage
    // 4. create a Firestore collection using the jobseeker data
    // 5. return errors at each stage.
};

Enter fullscreen mode Exit fullscreen mode

Finally, add the following code snippet within the jobSeekerSignUp function to upload the user's resume and headshot to Firebase storage and create a Firebase collection for the user.

export const jobSeekerSignUp = async (form: FormData) => {
    //πŸ‘‰πŸ» ...other functions for step 1-2
      //πŸ‘‡πŸ» creates a Firebase storage ref using the user's ID
    const cvRef = ref(storage, `resumes/${user.uid}/cv`);
    const imageRef = ref(storage, `applicants/${user.uid}/image`);

    //πŸ‘‡πŸ» uploads the image and CV to Firebase and create a Firebase collection
    await uploadBytes(cvRef, userData.cv).then(async () => {
        await uploadBytes(imageRef, userData.image).then(async () => {
            const [cvDownloadURL, imageDownloadURL] = await Promise.all([
                getDownloadURL(cvRef),
                getDownloadURL(imageRef),
 ]);
            if (!cvDownloadURL || !imageDownloadURL) {
                return {
                    code: "auth/failed",
                    status: 500,
                    user: null,
                    message: "Failed to upload cv or image",
 };
 }
            //πŸ‘‡πŸ» creates a Firebase collection including the user ID & download URLs
            const docRef = doc(db, "jobSeekers", user.uid);

            await setDoc(docRef, {
                name: userData.name,
                email: userData.email,
                bio: userData.bio,
                fieldOfInterest: userData.fieldOfInterest,
                portfolioUrl: userData.portfolioUrl,
                cv: cvDownloadURL,
                image: imageDownloadURL,
 });
 });
 });
    //πŸ‘‡πŸ» returns the user object and success messages

    return {
        code: "auth/success",
        status: 200,
        user,
        message: "Acount created successfully! πŸŽ‰",
 };
}
Enter fullscreen mode Exit fullscreen mode

The code snippet above accepts the user form data, uploads the resume and headshot image to Firebase storage, authenticates the user, and creates a Firebase collection containing the user's attributes.

To allow job seekers to sign into the application, add this function to the lib/auth-functions.ts file:

export const jobSeekerAuthLogin = async (form: FormData) => {
    //πŸ‘‡πŸ» Things to do here:
    // 1. accept the user's email and password
    // 2. authenticates and validate user credentials
    // 3. check if user exists in the jobSeekers Firebase collection
    // 4. create a Stream user using the data (later in the tutorial)
    // 5. return errors at each stage.
};
Enter fullscreen mode Exit fullscreen mode

Modify the function to perform the tasks listed above:

export const jobSeekerAuthLogin = async (form: FormData) => {
    const email = form.get("email") as string;
    const password = form.get("password") as string;
    //πŸ‘‡πŸ» authenticate user
    const { user } = await signInWithEmailAndPassword(auth, email, password);

    if (!user) {
        return {
            code: "auth/failed",
            status: 500,
            user: null,
            message: "Failed to login",
 };
 }
    //πŸ‘‡πŸ» check if user exists in the collection
    const userRef = doc(db, "jobSeekers", user.uid);
    const docSnap = await getDoc(userRef);

    if (!docSnap.exists()) {
        return {
            code: "auth/failed",
            status: 500,
            user: null,
            message: "User Not a Job Seeker",
 };
 }

    return {
        code: "auth/success",
        status: 200,
        user,
        message: "Login successful",
 };
};
Enter fullscreen mode Exit fullscreen mode

The code snippet above accepts the user's email and password, validates the credentials using Firebase Authentication, and checks if the user exists in the jobSeekers Firebase collection before granting access to the application.

Authenticating Recruiters

Here, you’ll learn about the necessary attributes for recruiters and how they can create an account, sign in, and sign out of the application.

Recruiter Registration

Copy the following code snippet into the lib/auth-functions.ts:

export const recruiterSignUp = async (form: FormData) => {
    //πŸ‘‡πŸ» Things to do here:
    // 1. get the form data
    // 2. create a user using the email and password
    // 3. upload the recruiter's image to Firebase storage
    // 4. create a Firestore collection using the recruiter data
    // 5. return errors at each stage.
};
Enter fullscreen mode Exit fullscreen mode

Modify the sign-up function as shown below:

export const recruiterSignUp = async (form: FormData) => {
    const userData: Recruiter = {
        name: form.get("name") as string,
        email: form.get("email") as string,
        companyName: form.get("companyName") as string,
        companyPosition: form.get("companyPosition") as string,
        password: form.get("password") as string,
        field: form.get("field") as Recruiter["field"],
        image: form.get("image") as File,
        url: form.get("url") as string,
 };

    const { user } = await createUserWithEmailAndPassword(
        auth,
        userData.email,
        userData.password
 );

    if (!user) {
        return {
            code: "auth/failed",
            status: 500,
            user: null,
            message: "Failed to create user",
 };
 }
    // 3. upload the recruiter's image to Firebase storage
    // 4. create a Firestore collection using the recruiter data
    // 5. return errors at each stage.
}
Enter fullscreen mode Exit fullscreen mode

The recruiterSignUp function accepts the form data, creates a Firebase user using the recruiter's email and password, and returns the user object.

Next, add the following code snippet within the recruiterSignUp function:

export const recruiterSignUp = async (form: FormData) => {
    //πŸ‘‰πŸ» Recruiter sign up function for steps 1-2

    const imageRef = ref(storage, `recruiters/${user.uid}/image`);
    await uploadBytes(imageRef, userData.image).then(async () => {
        const downloadURL = await getDownloadURL(imageRef);
        if (!downloadURL) {
            return {
                code: "auth/failed",
                status: 500,
                user: null,
                message: "Failed to upload image",
 };
 }
        const docRef = doc(db, "recruiters", user.uid);

        await setDoc(docRef, {
            name: userData.name,
            email: userData.email,
            companyName: userData.companyName,
            companyPosition: userData.companyPosition,
            field: userData.field,
            url: userData.url,
            image: downloadURL,
 });
 });

    return {
        code: "auth/success",
        status: 200,
        user,
        message: "Acount created successfully! πŸŽ‰",
 };
};
Enter fullscreen mode Exit fullscreen mode

The ID obtained from the user object is used to save the recruiter's image in Firebase Storage and to create a Firebase collection that includes the recruiter's attributes.

Create the following function to allow recruiters to sign into the application:

export const recruiterAuthLogin = async (form: FormData) => {
   const email = form.get("email") as string;
    const password = form.get("password") as string;

    const { user } = await signInWithEmailAndPassword(auth, email, password);

    if (!user) {
        return {
            code: "auth/failed",
            status: 500,
            user: null,
            message: "Failed to login",
 };
 }

    const userRef = doc(db, "recruiters", user.uid);
    const docSnap = await getDoc(userRef);

    if (!docSnap.exists()) {
        return {
            code: "auth/failed",
            status: 500,
            user: null,
            message: "User Not a Recruiter",
 };
 }

    return {
        code: "auth/success",
        status: 200,
        user,
        message: "Login successful",
 };
};
Enter fullscreen mode Exit fullscreen mode

The code snippet above validates the recruiter's credentials and checks if the user exists within the Firebase recruiters collection before signing the recruiter into the application.

Additionally, other functions include the authLogout and getJobSeekerData functions.

The authLogout function below allows both recruiters and job seekers to sign out of the application:

export const authLogout = async () => {
  try {
        await auth.signOut();
        return { code: "auth/success", status: 200, message: "Logout successful" };
 } catch (err) {
        return {
            code: "auth/failed",
            status: 500,
            message: "Failed to logout",
            err,
 };
 }
};
Enter fullscreen mode Exit fullscreen mode

The getJobSeekerData function retrieves all the attributes related to a job seeker and can be executed on the jobseeker’s profile page.

export const getUserProfile = async (uid: string) => {
    const userRef = doc(db, "jobSeekers", uid);
    const docSnap = await getDoc(userRef);

    if (!docSnap.exists()) {
        return {
            code: "auth/failed",
            status: 500,
            user: null,
            message: "Invalid ID",
 };
 }

    return {
        code: "auth/success",
        status: 200,
        user: docSnap.data() as JobSeekerFirebase,
        message: "User found",
 };
}
Enter fullscreen mode Exit fullscreen mode

Stream Chat Firebase Extension

In the previous sections, you learned how to authenticate users with Firebase. Stream also provides Firebase extensions that let you sync Firebase Authentication and Firestore with Stream. The Authenticate with Stream Chat extension automatically syncs Firebase users with Stream Chat, making it easier for Stream to manage your users.

Stream recommends using these Firebase extensions for easy integration and seamless communication between both platforms.

Alternatively, you can manually add users to Stream.

First, create an actions folder at the root of your Next.js project and add a stream.action.ts file:

mkdir actions && cd actions && \
touch stream.action.ts
Enter fullscreen mode Exit fullscreen mode

Copy the following code into the file:

"use server";
import { StreamChat } from "stream-chat";
const STREAM_API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!;
const STREAM_API_SECRET = process.env.STREAM_SECRET_KEY!;

const serverClient = StreamChat.getInstance(STREAM_API_KEY, STREAM_API_SECRET);

export const createStreamUser = async (
    id: string,
    name: string,
    image: string
) => {
    const { users } = await serverClient.queryUsers({ id });
    if (users.length > 0) return users[0];

    const user = await serverClient.upsertUser({
        id,
        name,
        image,
 });

    return user;
};
Enter fullscreen mode Exit fullscreen mode

The createStreamUser function takes a user ID, name, and image, checks if the user already exists in Stream, and adds them if they don’t. You can call this function right after a job seeker or recruiter logs into the appβ€”inside jobSeekerAuthLogin and recruiterAuthLogin.

To connect your Firebase users to Stream, create a Stream app and add your API credentials to the .env.local file:

NEXT_PUBLIC_STREAM_API_KEY=<your_Stream_API_key>
STREAM_SECRET_KEY=<your_Stream_Secret_key>
Enter fullscreen mode Exit fullscreen mode

Later in this tutorial, you'll learn about Stream's real-time chat and video calling features and discover how to integrate them into your application.


The Application Database Design

This section will teach you how to create Firestore Database collections and perform CRUD operations within the application.

Beyond the authentication pages, recruiters and job seekers have dedicated pages with specific functionalities.

Recruiter Page Routes

  • /dashboard – This is the recruiter's dashboard, where they can create job listings and view upcoming interview sessions.
  • /jobs – displays a list of jobs created by the recruiter, including the number of applicants. Recruiters can also delete a job if necessary.
  • /jobs/[id]/applicants – lists all applicants for a particular job along with their details, including profile links, downloadable CVs, cover letters, and notes to the employer. Each job in the /jobs route includes an applicant link that directs recruiters to the complete list of applicants and their information.

Job Seeker Page Routes

  • /[id] – displays the name, image, downloadable CV, and portfolio link for a job seeker.
  • /feed – shows job listings that match the job seeker’s interests or field (e.g., tech, consulting, etc.).
  • /jobs/[id] - displays the job description and a form that allows job seekers to apply.
  • /jobs/applied – enables job seekers to track the status of their applications, displaying a list of applied jobs and their respective statuses.

The table below outlines the attributes of the Firestore collections:

jobSeekers recruiters applications jobs
name name coverLetter jobTitle
email email jobID jobDescription
image image note expiryDate
cv url status applications: [{ applicationId, userId }]
bio field user : { jobSeekers attributes } recruiter: { recruiters attributes }
portfolioUrl companyName
fieldOfInterest companyPosition

From the table above:

  • The jobSeekers and recruiters collections store essential attributes for job seekers and recruiters, respectively.
  • The fieldOfInterest (for job seekers) and field (for recruiters) attributes help personalize job feeds by displaying only relevant job listings.
  • The applications collection contains a user object with all job seeker details. For larger applications, storing only the jobSeekerId is more efficient, allowing the system to fetch user details on demand.
  • The jobs collection includes an applications array that stores objects with applicationId and userId, representing submitted applications.
  • The jobs collection also contains a recruiter attribute that stores the whole recruiter object.

Database Operations for Recruiters

Recruiters can perform the following actions within the application:

  • Create job postings.
  • Delete a job posting using its ID.
  • Retrieve all jobs they have created.
  • View all applicants for a specific job, including their details.
  • Reject job applications.
  • Update the status of a job application.

Next, let's implement these functions. First, create a lib/db-functions.ts file inside the src folder of your Next.js project. Then, add the following imports at the top of the file:

import db from "./firebase";
import {
    addDoc,
    collection,
    deleteDoc,
    doc,
    getDocs,
    getDoc,
    where,
    query,
    updateDoc,
    arrayUnion,
    arrayRemove,
} from "firebase/firestore";
Enter fullscreen mode Exit fullscreen mode

Copy the following code snippet into the file:

export const createJobPosting = async (
    form: FormData,
    user: RecruiterFirebase
) => {
    const jobTitle = form.get("jobTitle") as string;
    const jobDescription = form.get("jobDescription") as string;
    const expiryDate = form.get("expiryDate") as string;

    // πŸ‘‡πŸ» add the job to the listing
    const docRef = await addDoc(collection(db, "jobs"), {
        jobTitle,
        jobDescription,
        expiryDate: formatDate(expiryDate),
        applications: [],
 ...user,
 });

    if (!docRef.id) {
        return { code: "job/failed", status: 500, message: "Failed to create job" };
 }

    return {
        code: "job/success",
        status: 200,
        message: "Job created successfully",
 };
};
Enter fullscreen mode Exit fullscreen mode

The createJobPosting function accepts a job title, description, expiry date, and the recruiter's user object as parameters. It then creates a new job listing in the database. This function is only available to the recruiters on their dashboard.

Create Job Posting

To delete a job, you can add a delete button on every job displayed to execute this function:

export const deleteJobPosting = async (doc_id: string) => {
    try {
        const docRef = doc(db, "jobs", doc_id);
        //πŸ‘‡πŸ» gets the job via ID
        const docSnap = await getDoc(docRef);
        if (!docSnap.exists()) {
            return { code: "job/failed", status: 404, message: "Job does not exist" };
 }
        const { applications } = docSnap.data() as JobProps;
        //πŸ‘‡πŸ» gets all its applicants and delete them
        for (const application of applications) {
            const applicationRef = doc(db, "applications", application.applicationId);
            await deleteDoc(applicationRef);
 }
        //πŸ‘‡πŸ» delete the job
        await deleteDoc(doc(db, "jobs", doc_id));
        return {
          code: "job/success",
            status: 200,
            message: "Job deleted successfully",
 };
 } catch (error) {
        return {
            code: "job/failed",
            status: 500,
            message: "Failed to delete job",
            error,
 };
 }
};
Enter fullscreen mode Exit fullscreen mode

The deleteJobPosting function first retrieves the job via its ID. If the job exists, it fetches all associated applications and deletes them before removing the job itself from the database. This ensures that no application in the database references a non-existent job.

You can get all recruiter posts by using their ID to search the jobs collection and return the job list.

export const getRecruiterJobs = async (recruiterId: string) => {
    const q = query(collection(db, "jobs"), where("id", "==", recruiterId));
    const querySnapshot = await getDocs(q);
    const jobs: JobProps[] = [];
    querySnapshot.forEach((doc) => {
        jobs.push({ doc_id: doc.id, ...doc.data() } as JobProps);
 });
    return jobs;
}
Enter fullscreen mode Exit fullscreen mode

Recruiters need to review applications for a specific job, so you should create a database function that retrieves all applications for that job to help them make a decision.

export const getJobApplicants = async (jobId: string) => {
    const docRef = doc(db, "jobs", jobId);
    //πŸ‘‡πŸ» get job data using its ID
    const docSnap = await getDoc(docRef);

    if (!docSnap.exists()) {
        return {
            code: "job/failed",
            status: 404,
            message: "Job does not exist",
            applicants: [],
 };
 }
    //πŸ‘‡πŸ» retrieves the applications array
    const { applications } = docSnap.data() as JobProps;
    const applicants: ApplicationProps[] = [];
    //πŸ‘‡πŸ» returns the entire application collections collection using the application ID
    for (const application of applications) {
        const applicationRef = doc(db, "applications", application.applicationId);
        const applicationSnap = await getDoc(applicationRef);
        if (applicationSnap.exists()) {
            applicants.push({
                id: application.applicationId,
 ...applicationSnap.data(),
 } as ApplicationProps);
 }
 }

    return {
        code: "job/success",
        status: 200,
        message: "Job applicants retrieved successfully",
        applicants,
 };
};

Enter fullscreen mode Exit fullscreen mode

The getJobApplicants function accepts the job ID as a parameter, fetches the job data, and extracts the applications array. Since the applications array contains the document IDs of each application, the function retrieves all applications using these IDs.

Finally, recruiters can reject applications and update a job application's status using the following functions:

//πŸ‘‡πŸ» reject applications
export const handleRejectApplication = async (
    application: ApplicationProps
) => {
    const applicationRef = doc(db, "applications", application.id);
    await deleteDoc(applicationRef);

    const jobRef = doc(db, "jobs", application.jobID);
    await updateDoc(jobRef, {
        applications: arrayRemove({
            applicationId: application.id,
            userId: application.user.id,
 }),
 });

    return {
        code: "job/success",
        status: 200,
        message: "Application rejected successfully",
 };
};

//πŸ‘‡πŸ» update job status
export const updateJobStatus = async (
    applicationId: string,
    status: ApplicationProps["status"]
) => {
    const applicationRef = doc(db, "applications", applicationId);
    await updateDoc(applicationRef, { status });
    return {
        code: "job/success",
        status: 200,
        message: "Application status updated successfully",
 };
};

Enter fullscreen mode Exit fullscreen mode

The handleRejectApplication function accepts the application Firebase document, extracts the job ID and application ID, and deletes the application using its ID. It then removes the applicant from the applications array in the jobs Firebase document, ensuring that the rejected application is fully removed from both collections.

The updateJobStatus function updates the status of a specific job application. This helps job seekers track their application status, whether pending or being reviewed. The function can be triggered when a recruiter chats with the job seeker or schedules an interview.

Database Operations for Job Seekers

Job seekers can perform the following key functions:

  • Browse available jobs: retrieve all job listings within their chosen field, ensuring that only jobs they haven't applied for are displayed.
  • View job details: fetch complete job details, including the job description, company name, and other relevant information, using the job ID. This page also provides a form to allow job seekers to apply after reviewing the requirements.
  • Track applications: view a list of jobs they have applied for and monitor their application status.

To display job listings on the job seeker's feed, use the following code snippet:

export const getJobFeed = async (user: JobSeekerFirebase) => {
    //πŸ‘‡πŸ» query jobs matching the user's field of interest
    const q = query(
        collection(db, "jobs"),
        where("field", "==", user.fieldOfInterest)
 );
    const querySnapshot = await getDocs(q);
    const jobs: JobProps[] = [];
    //πŸ‘‡πŸ» retreieve only jobs that the user hasn't apply for
    querySnapshot.forEach((doc) => {
        const data = { doc_id: doc.id, ...doc.data() } as JobProps;
        if (data.applications.some((application) => application.userId === user.id))
            return;
        jobs.push(data);
 });

    return jobs;
};
Enter fullscreen mode Exit fullscreen mode

The getJobFeed function takes a job seeker's user object as a parameter and returns job listings that match their field of interest and have not been applied for. This ensures they only see relevant opportunities.

To retrieve a job by its ID and allow job seekers to view its full details, use the following code snippet:

export const getJobById = async (doc_id: string) => {
    const docRef = doc(db, "jobs", doc_id);
    const docSnap = await getDoc(docRef);
    if (docSnap.exists()) {
      return { doc_id, ...docSnap.data() } as JobProps;
 } else {
        return null;
 }
};
Enter fullscreen mode Exit fullscreen mode

Next, you need to allow job seekers to apply for jobs.

export const applyForJob = async (
    jobID: string,
    user: JobSeekerFirebase,
    coverLetter: string,
    note: string
) => {
    ///πŸ‘‡πŸ» gets job document reference
    const jobRef = doc(db, "jobs", jobID);
    const jobSnap = await getDoc(jobRef);

    if (!jobSnap.exists()) {
        return { code: "job/failed", status: 404, message: "Job does not exist" };
 }

    const job = jobSnap.data() as JobProps;
    //πŸ‘‡πŸ» checks if user already applied
    const alreadyApplied = job.applications.find(
 (application) => application.userId === user.id
 );

    if (alreadyApplied) {
        return {
            code: "job/failed",
            status: 400,
            message: "You have already applied for this job",
 };
 }
    //πŸ‘‡πŸ» save job seeker's application
    const addResult = await addDoc(collection(db, "applications"), {
        jobID,
        user,
        coverLetter,
        note,
        status: "Pending",
     });

    if (!addResult.id) {
        return {
            code: "job/failed",
            status: 500,
            message: "Failed to submit application",
 };
 }
    //πŸ‘‡πŸ» update the job applications with the new application
    await updateDoc(jobRef, {
        applications: arrayUnion({ applicationId: addResult.id, userId: user.id }),
 });

    return {
        code: "job/success",
        status: 200,
        message: "Application submitted successfully",
 };
};

Enter fullscreen mode Exit fullscreen mode

The applyForJob function:

  • accepts the job ID, job seeker user object, cover letter, and note to the employer as parameters.
  • fetches the job document from Firestore and checks if the job exists.
  • ensures that the user has not already applied.
  • saves the job application to the Firestore application collection.
  • updates the job document with the new application.

Finally, you can retrieve all the jobs the user has applied for, including their status.

export const getJobsUserAppliedFor = async (userId: string) => {
    //πŸ‘‡ Fetch all applications for the user
    const q = query(
        collection(db, "applications"),
        where("user.id", "==", userId)
 );
    const querySnapshot = await getDocs(q);
    const applications: ApplicationProps[] = querySnapshot.docs.map((doc) => ({
        id: doc.id,
 ...doc.data(),
})) as ApplicationProps[];

    if (applications.length === 0) return []; // Return empty array if no applications exist

    //πŸ‘‡ Fetch all job documents in a single batch request
    const jobIds = applications.map((app) => doc(db, "jobs", app.jobID));
    const jobSnaps = await getDocs(query(collection(db, "jobs"), where("__name__", "in", jobIds.map((job) => job.id))));

    const jobs: JobProps[] = jobSnaps.docs.map((doc) => {
        const application = applications.find((app) => app.jobID === doc.id);
        return {
            doc_id: doc.id,
 ...doc.data(),
            status: application?.status || "Unknown", // Ensure status is included
 } as JobProps;
 });

    return jobs;
};

Enter fullscreen mode Exit fullscreen mode

The getJobsUserAppliedFor function fetches all job applications for a given user, retrieves the corresponding job details in a batch query, and associates each job with its application status. If no applications exist, it returns an empty array.


How to Integrate the Private Chat Feature with Stream

In this section, you'll learn how to create private channels and send messages to users within the application, enabling recruiters to chat with shortlisted candidates.

Before we proceed, install the following Stream packages to integrate chat and video call features into the application.

// πŸ‘‡πŸ» for Stream Chat SDK 
npm install stream-chat stream-chat-react 
//πŸ‘‡πŸ» for Stream Video & Audio SDK 
npm install @stream-io/node-sdk @stream-io/video-react-sdk
Enter fullscreen mode Exit fullscreen mode

Setting Up the Stream Chat SDK in Next.js

Create a Stream account and a new organization that holds all your apps.

Create Stream account

Add a new app to the organization and copy the Stream API and Secret key into the .env.local file.

NEXT_PUBLIC_STREAM_API_KEY=<paste_from_Stream_app_dashboard>
STREAM_SECRET_KEY=<paste_from_Stream_app_dashboard>
Enter fullscreen mode Exit fullscreen mode

Stream Dashboard

Within the actions/stream.action.ts file created earlier, copy the following code snippet into the file:

"use server";
import { StreamChat } from "stream-chat";
import { StreamClient } from "@stream-io/node-sdk";
const STREAM_API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!;
const STREAM_API_SECRET = process.env.STREAM_SECRET_KEY!;

// πŸ‘‡πŸ» -- Stream server client  --
const serverClient = StreamChat.getInstance(STREAM_API_KEY, STREAM_API_SECRET);

//πŸ‘‡πŸ» -- create auth token function
export async function createToken(
    user: RecruiterFirebase | JobSeekerFirebase
): Promise<string> {
    if (!user) throw new Error("User is not authenticated");
    return serverClient.createToken(user.id);
}
Enter fullscreen mode Exit fullscreen mode

The code snippet above initializes the Stream Chat server client, allowing us to perform various Stream actions on the server side. The createToken function accepts a user object (either a recruiter or job seeker) and creates an authentication token using the user's ID.

Since Firebase works on the client side, we also need a Stream client to perform actions such as fetching upcoming calls and channels, and creating and joining them.

To achieve this, create a hooks folder in your project and add a useGetStreamClient custom hook within a (stream) folder in your Next.js app. This hook will manage the client-side Stream functionality.

cd app && mkdir (stream) && \ 
cd (stream) && mkdir hooks && cd hooks && \
touch useGetStreamClient.ts
Enter fullscreen mode Exit fullscreen mode

Copy the following code snippet into the useGetStreamClient.ts:

import { useCreateChatClient } from "stream-chat-react";
import { createToken } from "../../../../actions/stream.action";
import { useCallback } from "react";

export const useGetStreamClient = (
    user: JobSeekerFirebase | RecruiterFirebase
    ) => {
    //πŸ‘‡πŸ» executes the createToken function from the server
    const tokenProvider = useCallback(async () => {
        return await createToken(user);
 }, [user]);

    //πŸ‘‡πŸ» creates the chat client
    const client = useCreateChatClient({
        apiKey: process.env.NEXT_PUBLIC_STREAM_API_KEY!,
        tokenOrProvider: tokenProvider,
        userData: { id: user.id, name: user.name, image: user.image },
 });
    //πŸ‘‡πŸ» returns the chat client
    if (!client) return { client: null };

    return { client };
};
Enter fullscreen mode Exit fullscreen mode

The useGetStreamClient hook generates a token for the current user and creates a chat client using the user's credentials from the user object. It then returns the chat client, which can be used to perform client-side Stream operations.

Private Chat with the Stream Chat SDK

In this section, you will learn how to create private chat channels using the Stream Chat SDK to enable recruiters to send messages to shortlisted job applicants. The chat channels allow recruiters to ask additional questions or interact with the applicant before and after the interview.

Copy the code snippet below into the stream.action.ts file:

export async function createChannel({
    recruiterId,
    applicant,
}: {
    recruiterId: string;
    applicant: ApplicationProps;
}) {
    try {
        const filter = {
            type: "messaging",
            members: { $in: [recruiterId, applicant.user.id] },
 };
  const sort = [{ last_message_at: -1 }];
        //πŸ‘‡πŸ» check if job applicant already has a channel with the recruiter
        const channels = await serverClient.queryChannels(filter, sort, {
            watch: true,
            state: true,
 });
        //πŸ‘‡πŸ» if true, return the channel id
        if (channels.length > 0) {
            return { success: true, error: null, id: channels[0].id };
 }
        //πŸ‘‡πŸ» otherwise, create a new channel for them
        const channel = serverClient.channel(
            "messaging",
            `${applicant.jobID}-chat-${applicant.user.id}`,
 {
                name: `Meeting with ${applicant.user.name}`,
                members: [recruiterId, applicant.user.id],
                created_by_id: recruiterId,
 }
 );
        await channel.create();
        //πŸ‘‡πŸ» return the channel id
        return { success: true, error: null, id: channel.id };
 } catch (err) {
        console.log("Error creating channel:", err);
        return { success: false, error: "Failed to create channel", id: null };
 }
}

Enter fullscreen mode Exit fullscreen mode

The createChannel function checks if a chat channel already exists between the recruiter and the job applicant. If such a channel exists, it returns the existing channel's ID. If not, the function creates a new channel and returns the channel ID. This allows recruiters and job applicants to visit the chat page via the /chat/[id] route.

Now, let's build the UI components for the chat page.

Create a chat/[id]/page.tsx file and copy the following code into the file:

"use client";
import StreamChatUI from "../components/StreamChatUI";
import { useState, useEffect, useCallback } from "react";
import { onAuthStateChanged, User } from "firebase/auth";
import { doc, getDoc } from "firebase/firestore";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { auth } from "@/lib/firebase";
import db from "@/lib/firebase";

export default function ChatPage() {
    //πŸ‘‡πŸ» Firebase User object
    const [user, setUser] = useState<User | null>(null);
    //πŸ‘‡πŸ» Firestore user collection states
    const [userData, setUserData] = useState<
        JobSeekerFirebase | RecruiterFirebase | null
    >(null);
    const router = useRouter();

    //πŸ‘‰πŸ» auth verification functions

    return (
 <div>{userData ? <StreamChatUI user={userData} /> : <ConfirmMember />}</div>
 );
}

const ConfirmMember = () => {
    const router = useRouter();
    return (
 <div className='flex flex-col items-center justify-center h-screen'>
 <button
                className='text-lg mb-4 p-4 bg-blue-500 text-white rounded-md'
                onClick={() => router.back()}
 >
 Go Back
 </button>

 <div className='loader'>
 <Loader2 size={48} className='animate-spin' />
 </div>
 </div>
 );
};
Enter fullscreen mode Exit fullscreen mode

The ChatPage component conditionally renders either the StreamChatUI component or the ConfirmMember component. The ConfirmMember component acts as a loading page and is displayed until the userData value is available.

Next, add the following authentication functions within the ChatPage component:

//πŸ‘‡πŸ» check if the user is authenticated and save its Firebase user object
useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, async (user) => {
        if (user?.uid) {
            setUser(user);
 } else {
            return router.push("/applicant/login");
 }
 });

    return () => unsubscribe();
}, [router]);

//πŸ‘‡πŸ» retrieve user's collection
const getUser = useCallback(async () => {
    if (!user) return null;
    const jobSeekerSnap = await getDoc(doc(db, "jobSeekers", user.uid));
    const recruiterSnap = await getDoc(doc(db, "recruiters", user.uid));

    if (jobSeekerSnap.exists()) {
        setUserData({
            id: user.uid,
 ...jobSeekerSnap.data(),
 } as JobSeekerFirebase);
 } else if (recruiterSnap.exists()) {
        setUserData({
            id: user.uid,
 ...recruiterSnap.data(),
 } as JobSeekerFirebase);
 } else {
        return null;
 }
}, [user]);

useEffect(() => {
    getUser();
}, [getUser]);
Enter fullscreen mode Exit fullscreen mode

The code snippet above retrieves the user's Firebase collection when the page loads before passing the userData React state into the StreamChatUI component.

Copy the following code snippet into StreamChatUI component:

"use client";
import {
    Chat,
    Channel,
    ChannelList,
    Window,
    ChannelHeader,
    MessageList,
    MessageInput,
} from "stream-chat-react";
import { useGetStreamClient } from "../../hooks/useGetStreamClient";

export default function StreamChatUI({
    user,
}: {
    user: JobSeekerFirebase | RecruiterFirebase;
}) {
    //πŸ‘‡πŸ» retrieves the chat client
    const { client } = useGetStreamClient(user!);

    //πŸ‘‡πŸ» filter and sorting objects
    const filters = { members: { $in: [user.id] }, type: "messaging" };
    const options = { presence: true, state: true };

    if (!client) return <div>Loading...</div>;

    return (
 <Chat client={client}>
 <div className='chat-container'>
                {/* -- Channel List -- */}
 <div className='channel-list'>
 <ChannelList
                        sort={{ last_message_at: -1 }}
                        filters={filters}
                        options={options}
 />
 </div>
      {/* -- Messages Panel -- */}
 <div className='chat-panel'>
 <Channel>
 <Window>
 <ChannelHeader />
 <MessageList />
 <MessageInput />
 </Window>
 </Channel>
 </div>
 </div>
 </Chat>
 );
}

Enter fullscreen mode Exit fullscreen mode

From the code snippet above:

  • The useGetStreamClient custom hook returns the chat client.
  • Chat component initializes the Stream Chat client and wraps the entire Chat page.
  • ChannelList shows available chat channels.
  • Channel sets up an active chat session.
  • Window contains the message display and input areas.
  • ChannelHeader, MessageList, and MessageInput provide a fully functional chat interface.

How to Schedule Virtual Interviews with Stream

In this section, you'll learn how to integrate video calling functionality into the Next.js application, allowing recruiters to schedule and conduct virtual interviews with job seekers.

Copy the following code snippet into the actions/stream.action.ts file:

export const tokenProvider = async (user_id: string) => {
    if (!STREAM_API_KEY) throw new Error("Stream API key secret is missing");
    if (!STREAM_API_SECRET) throw new Error("Stream API secret is missing");

    const streamClient = new StreamClient(STREAM_API_KEY, STREAM_API_SECRET);

    const expirationTime = Math.floor(Date.now() / 1000) + 3600;
    const issuedAt = Math.floor(Date.now() / 1000) - 60;
    const token = streamClient.generateUserToken({
        user_id,
        exp: expirationTime,
        validity_in_seconds: issuedAt,
 });

    return token;
}
Enter fullscreen mode Exit fullscreen mode

The tokenProvider function generates an authentication token for the user, enabling Stream to identify and manage users during real-time communication.

Within the (stream)/hooks folder, add the following files:

cd app && cd (stream)/hooks && \
touch useGetCallById.ts useGetCalls.ts
Enter fullscreen mode Exit fullscreen mode

The useGetCallById file defines a React hook that fetches details of a specific Stream call via its ID while the useGetCalls hook retrieves all the calls created by a particular Stream user.

Let's create these custom React hooks.

Copy the following code snippet into the useGetCallById.ts file:

import { useEffect, useState } from "react";
import { Call, useStreamVideoClient } from "@stream-io/video-react-sdk";

export const useGetCallById = (id: string | string[]) => {
    const [call, setCall] = useState<Call>();
    const [isCallLoading, setIsCallLoading] = useState(true);

    const client = useStreamVideoClient();

    useEffect(() => {
        if (!client) return;

        const loadCall = async () => {
            try {
                // https://getstream.io/video/docs/react/guides/querying-calls/#filters
                const { calls } = await client.queryCalls({
        filter_conditions: { id },
 });

                if (calls.length > 0) setCall(calls[0]);

                setIsCallLoading(false);
 } catch (error) {
                console.error(error);
                setIsCallLoading(false);
 }
 };

        loadCall();
 }, [client, id]);

    return { call, isCallLoading };
};

Enter fullscreen mode Exit fullscreen mode

Add the following to the useGetCalls.ts file:

import { useEffect, useState } from "react";
import { Call, useStreamVideoClient } from "@stream-io/video-react-sdk";

export const useGetCalls = (id: string) => {
    const client = useStreamVideoClient();
    const [calls, setCalls] = useState<Call[]>();
    const [isLoading, setIsLoading] = useState(false);

    useEffect(() => {
        const loadCalls = async () => {
            if (!client || !id) return;

            setIsLoading(true);

            try {
                const { calls } = await client.queryCalls({
                    sort: [{ field: "starts_at", direction: 1 }],
                    filter_conditions: {
                        starts_at: { $exists: true },
                        $or: [{ created_by_user_id: id }, { members: { $in: [id] } }],
 },
 });
   setCalls(calls);
 } catch (error) {
                console.error(error);
 } finally {
                setIsLoading(false);
 }
 };

        loadCalls();
 }, [client, id]);

    const now = new Date();

    const upcomingCalls = calls?.filter(({ state: { startsAt } }: Call) => {
        return startsAt && new Date(startsAt) > now;
 });

    const ongoingCalls = calls?.filter(
 ({ state: { startsAt, endedAt } }: Call) => {
            return startsAt && new Date(startsAt) < now && !endedAt;
 }
 );

    return { upcomingCalls, isLoading, ongoingCalls };
};

Enter fullscreen mode Exit fullscreen mode

The useGetCalls hook retrieves all calls created by the recruiter, including ongoing and upcoming calls. It also provides an isLoading state to indicate when data is being fetched, enabling conditional rendering.

Next, to create, join, and retrieve calls, wrap the pages that require access to the calls with the StreamVideo component.

Add a providers/StreamVideoProvider component inside the (stream) folder to do this. Then, copy the following code snippet into the file:

"use client";
import { tokenProvider } from "../../../../actions/stream.action";
import { StreamVideo, StreamVideoClient } from "@stream-io/video-react-sdk";
import { useState, ReactNode, useEffect, useCallback } from "react";
import { onAuthStateChanged, User } from "firebase/auth";
import { auth } from "@/lib/firebase";
import db from "@/lib/firebase";
import { doc, getDoc } from "firebase/firestore";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";

const apiKey = process.env.NEXT_PUBLIC_STREAM_API_KEY!;

export const StreamVideoProvider = ({ children }: { children: ReactNode }) => {
    const [videoClient, setVideoClient] = useState<StreamVideoClient | null>(
        null
 );

    const [user, setUser] = useState<User | null>(null);
    const router = useRouter();

    // πŸ‘‰πŸ» auth functions placeholder

    return <StreamVideo client={videoClient}>{children}</StreamVideo>;
};

Enter fullscreen mode Exit fullscreen mode

Update the component with the authentication functions:

//πŸ‘‡πŸ» get the Firebase user object
useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, async (user) => {
        if (user?.uid) {
            setUser(user);
 } else {
            return router.push("/applicant/login");
 }
 });

    return () => unsubscribe();
}, [router]);

//πŸ‘‡πŸ» get the Firebase user collection and returns the Stream video client
const getUser = useCallback(async () => {
    if (!user) return null;

    const [jobSeekerSnap, recruiterSnap] = await Promise.all([
        getDoc(doc(db, "jobSeekers", user.uid)),
        getDoc(doc(db, "recruiters", user.uid)),
 ]);

    if (!jobSeekerSnap.exists() && !recruiterSnap.exists()) {
        console.warn("User data not found in Firestore");
        return null;
 }

    return new StreamVideoClient({
        apiKey,
        user: {
            id: user.uid,
            name: jobSeekerSnap.data()?.name || recruiterSnap.data()?.name,
            image: jobSeekerSnap.data()?.image || recruiterSnap.data()?.image,
 },
        tokenProvider: () => tokenProvider(user.uid),
 });
}, [user]);

//πŸ‘‡πŸ» update the video client

useEffect(() => {
    const result = getUser();
    if (result) {
        result.then((client) => setVideoClient(client));
 }
}, [getUser]);

if (!videoClient)
    return (
 <div className='h-screen flex items-center justify-center'>
 <Loader2 size='32' className='mx-auto animate-spin' />
 </div>
 );
Enter fullscreen mode Exit fullscreen mode

The code snippet above does the following:

  • retrieves the authenticated Firebase user object and redirects them to the login page if they are not signed in.
  • fetches the user's data from the jobSeekers or recruiters Firestore collection and initializes a StreamVideoClient with their details.
  • updates the videoClient state once the user data is retrieved, displaying a loading spinner while waiting for the client to be set.

Finally, recruiters need to be able to schedule calls with shortlisted applicants. They can execute this function after submitting a form that accepts the meeting title and a specific date and time for the interview.

import { useStreamVideoClient } from "@stream-io/video-react-sdk";
const client = useStreamVideoClient();

const handleScheduleMeeting = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!description || !dateTime || !client) return;

    try {
        const id = crypto.randomUUID(); //πŸ‘‰πŸ» generates a random id
        const call = client.call("default", id);
        if (!call) throw new Error("Failed to create meeting");
        // πŸ‘‡πŸ» creates a call
        await call.getOrCreate({
            data: {
                starts_at: new Date(dateTime).toISOString(),
                custom: {
                    description,
 },
                members: [{ user_id: applicantId }, { user_id: recruiterId }],
 },
 });
 } catch (error) {
        console.error(error);
 }
};
Enter fullscreen mode Exit fullscreen mode

The code snippet above creates a Stream video call with a default call type. It assigns the call a unique ID, sets the scheduled date and time, and includes a custom description.

Ensure that the <StreamVideoProvider> component wraps the recruiter's dashboard where the video call is created. You can achieve this by adding a layout.tsx file to the dashboard page and wrapping all child elements with <StreamVideoProvider>.

Stream Call UI Components

Here, you will learn how to create the interview page, where the recruiter and the applicant can communicate via video call, share screens, and seamlessly meet.

First, create an interview/[id]/page.tsx file within the (stream) folder and add the following imports to the file:

"use client";
import { useGetCallById } from "../../hooks/useGetCallById";
import {
    StreamCall,
    StreamTheme,
    PaginatedGridLayout,
    SpeakerLayout,
    CallControls,
    Call,
} from "@stream-io/video-react-sdk";
import { useParams } from "next/navigation";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useCallStateHooks } from "@stream-io/video-react-sdk";
import { toast } from "sonner";
import { auth } from "@/lib/firebase";
import { User, onAuthStateChanged } from "firebase/auth";

type CallLayoutType = "grid" | "speaker-left" | "speaker-right";
Enter fullscreen mode Exit fullscreen mode

Copy the following code snippet into the file to ensure the user is authenticated before rendering the call component.

export default function CallPage() {
    const { id } = useParams<{ id: string }>();
    const { call, isCallLoading } = useGetCallById(id);
    const [confirmJoin, setConfirmJoin] = useState<boolean>(false);
    const [camMicEnabled, setCamMicEnabled] = useState<boolean>(false);
    const [user, setUser] = useState<User | null>(null);
    const router = useRouter();

    const authenticateUser = useCallback(async () => {
        const unsubscribe = onAuthStateChanged(auth, async (user) => {
            if (!user?.uid) {
                return router.push("/applicant/login");
     }
            setUser(user);

            if (camMicEnabled) {
                call?.camera.enable();
                call?.microphone.enable();
 } else {
                call?.camera.disable();
                call?.microphone.disable();
 }
 });

        return () => unsubscribe();
 }, [router, call, camMicEnabled]);

    useEffect(() => {
        authenticateUser();
 }, [authenticateUser]);

    const handleJoin = () => {
        call?.join();
        setConfirmJoin(true);
 };

    if (isCallLoading) return <p>Loading...</p>;

    if (!call) return <p>Call not found</p>;

    return (
        // --- Call UI component ---
 )
}
Enter fullscreen mode Exit fullscreen mode

The authenticateUser function checks if a user is logged in; if not, it redirects them to the login page. If authentication is successful, it enables or disables the user's camera and microphone based on the camMicEnabled state. The handleJoin function allows users to join the call when they confirm participation.

Return the following UI elements from the CallPage component:

return (
 <main className='min-h-screen w-full items-center justify-center'>
 <StreamCall call={call}>
 <StreamTheme>
                {confirmJoin ? (
 <MeetingRoom call={call} />
 ) : (
 <div className='flex flex-col items-center justify-center gap-5 h-screen w-full'>
 <h1 className='text-3xl font-bold'>Join Call</h1>
 <p className='text-lg'>Are you sure you want to join this call?</p>
 <div className='flex gap-5'>
 <button
                                onClick={handleJoin}
                                className='px-4 py-3 bg-blue-600 text-blue-50'
 >
 Join
 </button>
 <button
                                onClick={() => router.back()}
                                className='px-4 py-3 bg-red-600 text-red-50'
 >
 Cancel
 </button>
 </div>
 </div>
 )}
 </StreamTheme>
 </StreamCall>
 </main>
)
Enter fullscreen mode Exit fullscreen mode

The component displays a confirmation page where the participant can confirm their decision before joining the interview or call session.

In the code snippet above,

  • The StreamCall component wraps the entire call page, allowing access to various audio and video calling features. It accepts the call object as a prop.
  • The StreamTheme component provides UI styling for the call, enabling you to use different themes.
  • The confirmJoin state is initially set to false. When the user clicks the Join button, it triggers the handleJoin function, which joins the call and updates confirmJoin to true.
  • When confirmJoin is true, the component renders the MeetingRoom component, which includes all prebuilt and customizable UI elements for the call provided by Stream.

Add the MeetingRoom component to the interview/[id]/page.tsx file:

const MeetingRoom = ({ call }: { call: Call }) => {
    const [layout, setLayout] = useState<CallLayoutType>("grid");
    const router = useRouter();

    const handleLeave = () => {
        if (confirm("Are you sure you want to leave the call?")) {
            router.push("/");
 }
 };

    const CallLayout = () => {
        switch (layout) {
            case "grid":
                return <PaginatedGridLayout />;
            case "speaker-right":
                return <SpeakerLayout participantsBarPosition='left' />;
            default:
                return <SpeakerLayout participantsBarPosition='right' />;
 }
 };

    return (
 <section className='relative min-h-screen w-full overflow-hidden pt-4'>
 <div className='relative flex size-full items-center justify-center'>
 <div className='flex size-full max-w-[1000px] items-center'>
 <CallLayout />
 </div>
 <div className='fixed bottom-0 flex w-full items-center justify-center gap-5'>
 <CallControls onLeave={handleLeave} />
 </div>

 <div className='fixed bottom-0 right-0 flex items-center justify-center gap-5 p-5'>
 <EndCallButton call={call} />
 </div>
 </div>
 </section>
 );
};
Enter fullscreen mode Exit fullscreen mode

The CallLayout and CallControls components are rendered on the page, allowing users to communicate, share their screen, turn their camera on or off, and engage in conversations through reactions.

Finally, create the EndCallButton component to enable the host (instructor) to end the call for everyone.

const EndCallButton = ({ call }: { call: Call }) => {
    const { useLocalParticipant } = useCallStateHooks();
    const localParticipant = useLocalParticipant();
    const router = useRouter();

    const participantIsHost =
        localParticipant &&
        call.state.createdBy &&
        localParticipant.userId === call.state.createdBy.id;

    if (!participantIsHost) return null;

    const handleEndCall = () => {
        call.endCall();
        toast("Call ended for everyone", {
            description: "The call has been ended for everyone",
 });
        router.push("/");
 };

    return (
 <button
            className='bg-red-500 text-white px-4 py-2 rounded-md mt-2'
            onClick={handleEndCall}
 >
 End Call for Everyone
 </button>
 );
};
Enter fullscreen mode Exit fullscreen mode

The code snippet above ensures that only the call host can end the call for all participants. It first checks if the current user is the host before displaying the "End Call for Everyone" button.

Congratulations! You’ve completed the project for this tutorial. The source code for this article is also available on GitHub.


Next Steps

So far, you've learned how to build a full-stack job application and interview platform using Stream and Firebase. This platform allows recruiters to shortlist applicants, schedule virtual interviews, and communicate through real-time chat powered by Stream.

Stream helps you build engaging apps that scale to millions with performant and flexible Chat, Video, Voice, Feeds, and Moderation APIs and SDKs powered by a global edge network and enterprise-grade infrastructure.

Here are some valuable resources to help you get started:

Thank you for reading. πŸŽ‰

Top comments (6)

Collapse
 
tyaga001 profile image
Ankur Tyagi

very detailed walkthrough and nicely written David. well done.

Collapse
 
arshadayvid profile image
David Asaolu

Thank you, @tyaga001 πŸŽ‰
Glad you found it useful! πŸ”₯

Collapse
 
getstream profile image
Stream

good work David

Collapse
 
arshadayvid profile image
David Asaolu

Thank you!
Stream is the best! πŸ”₯πŸš€

Collapse
 
arindam_1729 profile image
Arindam Majumder

Amazing Work David!

Collapse
 
arshadayvid profile image
David Asaolu

Thank you, Arindam!
Glad you found it useful.