Vibe coding with AI course · Module 7: LinkedIn Clone

Job Posts and Applications - Recruitment

8 min read
In this lesson4

LinkedIn isn't only profiles, it's also a job market. The captain of the Black Pearl is looking for a first mate, and a notice nailed to the tavern door will be read by a few passers-by at most. We'll build job posts the whole fleet will see, and a form for signing on.

The job post and application model

A job post and an application are two separate interfaces:

interface JobPost {
  id: string;
  title: string; // "First Mate"
  company: {
    id: string;
    name: string;
    logo: string;
    industry: string;
    size: string; // "11-50 employees"
  };
  location: string;
  type: 'full-time' | 'part-time' | 'contract' | 'temporary';
  level: 'entry' | 'mid' | 'senior' | 'executive';
  salary?: {
    min: number;
    max: number;
    currency: string;
  };
  description: string;
  requirements: string[];
  responsibilities: string[];
  skills: string[];
  postedDate: Date;
  applicants: number;
  views: number;
}

interface Application {
  id: string;
  jobId: string;
  applicant: {
    id: string;
    name: string;
    avatar: string;
    headline: string;
  };
  coverLetter: string;
  resume?: string; // URL
  status: 'pending' | 'reviewed' | 'interview' | 'rejected' | 'accepted';
  appliedDate: Date;
}

JobPost has a title, a company, a location, requirements, responsibilities, skills and the number of applicants (applicants). The type field accepts only full-time, part-time, contract or temporary, and level a grade from entry to executive. Thanks to union types like these, TypeScript catches a typo. The optional salary holds min, max and the currency (currency), and the question mark means that not every offer shows a salary range. Application links the offer (jobId) with the candidate, the cover letter and the resume URL, and tracks the application with status: pending, reviewed, interview, rejected or accepted. The interfaces only describe data, they don't send anything yet.

The job card

In the list, each offer is a card with a logo, a title, skills and a salary:

function JobCard({ job }: { job: JobPost }) {
  const [isSaved, setIsSaved] = useState(false);

  const timeAgo = (date: Date) => {
    const days = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
    if (days === 0) return 'Today';
    if (days === 1) return 'Yesterday';
    if (days < 7) return `${days} days ago`;
    if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
    return `${Math.floor(days / 30)} months ago`;
  };

  return (
    <div className="bg-white border rounded-lg p-6 hover:shadow-lg transition-shadow cursor-pointer">
      <div className="flex gap-4">
        {/* Company Logo */}
        <img
          src={job.company.logo}
          alt={job.company.name}
          className="w-16 h-16 rounded"
        />

        <div className="flex-1">
          {/* Title */}
          <h3 className="text-xl font-bold text-blue-600 hover:underline mb-1">
            {job.title}
          </h3>

          {/* Company */}
          <p className="text-gray-900 font-medium mb-1">{job.company.name}</p>

          {/* Location & Type */}
          <p className="text-sm text-gray-600 mb-2">
            {job.location} • {job.type.replace('-', ' ')} • {job.level}-level
          </p>

          {/* Salary */}
          {job.salary && (
            <p className="text-sm text-gray-700 font-medium mb-2">
              {job.salary.currency}{job.salary.min.toLocaleString()} -{' '}
              {job.salary.currency}{job.salary.max.toLocaleString()}
            </p>
          )}

          {/* Skills */}
          <div className="flex flex-wrap gap-2 mb-3">
            {job.skills.slice(0, 3).map((skill, idx) => (
              <span
                key={idx}
                className="px-2 py-1 bg-gray-100 rounded text-xs font-medium"
              >
                {skill}
              </span>
            ))}
            {job.skills.length > 3 && (
              <span className="px-2 py-1 text-xs text-gray-600">
                +{job.skills.length - 3} more
              </span>
            )}
          </div>

          {/* Stats */}
          <div className="flex items-center gap-4 text-xs text-gray-500">
            <span>{job.applicants} applicants</span>
            <span>•</span>
            <span>{timeAgo(job.postedDate)}</span>
          </div>
        </div>

        {/* Save Button */}
        <button
          onClick={(e) => {
            e.stopPropagation();
            setIsSaved(!isSaved);
          }}
          className="self-start text-sm font-semibold hover:scale-110 transition"
        >
          {isSaved ? 'Saved' : 'Save'}
        </button>
      </div>
    </div>
  );
}

The info row takes the form job.location • job.type • job.level-level, and replace('-', ' ') turns full-time into full time. The salary range is salary.min - salary.max: you read it through job.salary.min and job.salary.max, and the toLocaleString() method adds thousands separators that match the browser's language. You see the first three skills and a count of the rest. The isSaved state remembers whether the offer is saved for later, like the Save button on LinkedIn, and e.stopPropagation() makes sure that clicking the save icon doesn't open the whole offer.

timeAgo checks its conditions from the smallest time difference: days === 0 gives Today, days === 1 Yesterday, days < 7 a number of days, days < 30 a number of weeks, and after that months. The order matters, because the first condition that matches wins.

Job details

After clicking a card, the candidate lands on the full job page:

'use client';

import { useState } from 'react';

export default function JobDetailPage({ job }: { job: JobPost }) {
  const [showApplyModal, setShowApplyModal] = useState(false);

  return (
    <div className="max-w-6xl mx-auto p-4">
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
        {/* Main Content */}
        <div className="lg:col-span-2 space-y-4">
          {/* Header */}
          <div className="bg-white rounded-lg shadow p-6">
            <div className="flex gap-4 mb-4">
              <img
                src={job.company.logo}
                alt={job.company.name}
                className="w-20 h-20 rounded"
              />
              <div className="flex-1">
                <h1 className="text-3xl font-bold mb-2">{job.title}</h1>
                <p className="text-lg text-gray-900 mb-1">{job.company.name}</p>
                <p className="text-gray-600">
                  {job.location} • {job.type} • {job.level}
                </p>
              </div>
            </div>

            <div className="flex items-center gap-3 mb-4">
              <button
                onClick={() => setShowApplyModal(true)}
                className="px-8 py-3 bg-blue-600 text-white rounded-full font-bold hover:bg-blue-700 transition"
              >
                Apply Now
              </button>
              <button className="px-6 py-3 border-2 border-blue-600 text-blue-600 rounded-full font-bold hover:bg-blue-50">
                Save
              </button>
            </div>

            <div className="flex items-center gap-4 text-sm text-gray-600">
              <span>{job.applicants} applicants</span>
              <span>•</span>
              <span>{job.views} views</span>
              <span>•</span>
              <span>Posted {timeAgo(job.postedDate)}</span>
            </div>
          </div>

          {/* Description */}
          <div className="bg-white rounded-lg shadow p-6">
            <h2 className="text-xl font-bold mb-3">About the job</h2>
            <p className="text-gray-700 whitespace-pre-wrap mb-6">
              {job.description}
            </p>

            <h3 className="font-bold text-lg mb-2">Responsibilities</h3>
            <ul className="list-disc list-inside space-y-1 mb-6 text-gray-700">
              {job.responsibilities.map((resp, idx) => (
                <li key={idx}>{resp}</li>
              ))}
            </ul>

            <h3 className="font-bold text-lg mb-2">Requirements</h3>
            <ul className="list-disc list-inside space-y-1 text-gray-700">
              {job.requirements.map((req, idx) => (
                <li key={idx}>{req}</li>
              ))}
            </ul>
          </div>
        </div>

        {/* Sidebar */}
        <div className="space-y-4">
          {/* Company Info */}
          <div className="bg-white rounded-lg shadow p-6">
            <h3 className="font-bold mb-3">About {job.company.name}</h3>
            <p className="text-sm text-gray-600 mb-3">{job.company.industry}</p>
            <p className="text-sm text-gray-600 mb-3">{job.company.size}</p>
            <button className="w-full py-2 border-2 border-blue-600 text-blue-600 rounded-full font-semibold hover:bg-blue-50">
              View Company Page
            </button>
          </div>

          {/* Required Skills */}
          <div className="bg-white rounded-lg shadow p-6">
            <h3 className="font-bold mb-3">Skills</h3>
            <div className="flex flex-wrap gap-2">
              {job.skills.map((skill, idx) => (
                <span
                  key={idx}
                  className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium"
                >
                  {skill}
                </span>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

The main column contains the description, responsibilities and requirements, and the side column the company info and the required skills. The Apply Now button calls setShowApplyModal(true). The page also uses timeAgo, but that function lives inside JobCard, so move it to a shared file, e.g. utils.ts.

The application modal

Applying goes like this: click Apply Now, open ApplyModal, fill in the cover letter and add a resume, and finally submit. The modal itself looks like this:

'use client';

import { useState } from 'react';

interface ApplyModalProps {
  job: JobPost;
  onClose: () => void;
  onSubmit: (application: Partial<Application>) => void;
}

export default function ApplyModal({ job, onClose, onSubmit }: ApplyModalProps) {
  const [coverLetter, setCoverLetter] = useState('');
  const [resumeFile, setResumeFile] = useState<File | null>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    onSubmit({
      jobId: job.id,
      coverLetter,
      status: 'pending',
      appliedDate: new Date(),
    });

    onClose();
  };

  return (
    <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
      <div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
        <div className="flex items-center justify-between p-6 border-b sticky top-0 bg-white">
          <div>
            <h2 className="text-2xl font-bold">Apply to {job.company.name}</h2>
            <p className="text-gray-600">{job.title}</p>
          </div>
          <button
            onClick={onClose}
            className="text-gray-500 hover:text-gray-700 text-3xl"
          >
            ×
          </button>
        </div>

        <form onSubmit={handleSubmit} className="p-6 space-y-6">
          {/* Contact Info (pre-filled from profile) */}
          <div>
            <h3 className="font-bold mb-3">Contact Information</h3>
            <div className="bg-gray-50 p-4 rounded-lg">
              <p className="text-sm text-gray-600">
                Your profile information will be shared with the employer
              </p>
            </div>
          </div>

          {/* Resume */}
          <div>
            <label className="block font-bold mb-2">Resume</label>
            <input
              type="file"
              accept=".pdf,.doc,.docx"
              onChange={(e) => setResumeFile(e.target.files?.[0] || null)}
              className="w-full p-3 border rounded-lg"
            />
            <p className="text-xs text-gray-500 mt-1">
              PDF, DOC, or DOCX (max 5MB)
            </p>
          </div>

          {/* Cover Letter */}
          <div>
            <label className="block font-bold mb-2">
              Cover Letter (Optional)
            </label>
            <textarea
              value={coverLetter}
              onChange={(e) => setCoverLetter(e.target.value)}
              className="w-full p-3 border rounded-lg resize-none"
              rows={8}
            />
            <p className="text-xs text-gray-500 mt-1">
              {coverLetter.length} characters
            </p>
          </div>

          {/* Submit */}
          <div className="flex gap-3">
            <button
              type="submit"
              className="flex-1 py-3 bg-blue-600 text-white rounded-full font-bold hover:bg-blue-700"
            >
              Submit Application
            </button>
            <button
              type="button"
              onClick={onClose}
              className="px-6 py-3 border-2 border-gray-300 rounded-full font-bold hover:bg-gray-50"
            >
              Cancel
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

handleSubmit first calls e.preventDefault(), which stops the page from reloading, then onSubmit with jobId, coverLetter, status and appliedDate, and finally onClose() closes the modal. A new application gets the pending status, because nobody has reviewed it yet. Partial<Application> makes all fields optional, because the server fills in the rest. Notice that resumeFile goes into state but not into onSubmit. You'll send the resume file with FormData, just like the video in the file upload lesson.

Captain Redbeard's tip: write a closed set of values, such as a contract type or a status, as a union type, not a plain string. The compiler will then keep the course for you.

Remember: a job post is a notice with a salary range and requirements, and an application is a submission whose status starts at pending. In the editor below you have the pirate fleet's job board with job cards, and the tasks will check the job post fields and the application flow.

Code for this lesson: App.jsx
// Job Card - Job Listings
import { useState } from 'react';

const jobs = [
  {
    id: 1,
    title: 'First Mate',
    company: { name: 'Black Pearl Crew', logo: '' },
    location: 'Caribbean Sea',
    type: 'full-time',
    level: 'mid',
    salary: { min: 50000, max: 75000, currency: '$' },
    skills: ['Navigation', 'Leadership', 'Combat', 'Cartography'],
    applicants: 34,
    postedDays: 2
  },
  {
    id: 2,
    title: 'Treasure Navigator',
    company: { name: 'Flying Dutchman', logo: '' },
    location: 'Atlantic Ocean',
    type: 'contract',
    level: 'senior',
    salary: { min: 80000, max: 120000, currency: '$' },
    skills: ['Map Reading', 'Treasure Detection', 'Diving'],
    applicants: 67,
    postedDays: 5
  }
];

function JobCard({ job }) {
  const [isSaved, setIsSaved] = useState(false);

  return (
    <div className="bg-white border rounded-lg p-6 hover:shadow-lg transition-shadow">
      <div className="flex gap-4">
        <div className="text-5xl">{job.company.logo}</div>

        <div className="flex-1">
          <h3 className="text-xl font-bold text-blue-600 hover:underline mb-1 cursor-pointer">
            {job.title}
          </h3>
          <p className="text-gray-900 font-medium mb-1">{job.company.name}</p>
          <p className="text-sm text-gray-600 mb-2">
            {job.location} • {job.type} • {job.level}-level
          </p>

          {job.salary && (
            <p className="text-sm text-gray-700 font-medium mb-2">
              {job.salary.currency}{job.salary.min.toLocaleString()} - {job.salary.currency}{job.salary.max.toLocaleString()}
            </p>
          )}

          <div className="flex flex-wrap gap-2 mb-3">
            {job.skills.slice(0, 3).map((skill, idx) => (
              <span key={idx} className="px-2 py-1 bg-gray-100 rounded text-xs font-medium">
                {skill}
              </span>
            ))}
            {job.skills.length > 3 && (
              <span className="px-2 py-1 text-xs text-gray-600">+{job.skills.length - 3} more</span>
            )}
          </div>

          <div className="flex items-center gap-4 text-xs text-gray-500">
            <span>{job.applicants} applicants</span>
            <span>•</span>
            <span>{job.postedDays === 0 ? 'Today' : `${job.postedDays} days ago`}</span>
          </div>
        </div>

        <button
          onClick={() => setIsSaved(!isSaved)}
          className="self-start text-sm font-semibold hover:scale-110 transition"
        >
          {isSaved ? 'Saved' : 'Save'}
        </button>
      </div>
    </div>
  );
}

export default function App() {
  return (
    <div className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold mb-6">Pirate Job Board</h1>
        <div className="space-y-4">
          {jobs.map(job => (
            <JobCard key={job.id} job={job} />
          ))}
        </div>
      </div>
    </div>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does the JobPost interface contain?

  2. 2. What values can the type field have in JobPost?

These are 2 of 6 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the steps of applying for a job post:

  • Code editor

    Job listing card

  • Vertical ordering

    Order the conditions in the timeAgo function from the smallest time difference to the largest:

  • Click in order

    Arrange reading the minimum salary from the offer: job.salary.min

  • Click in order

    Arrange the call that opens the application modal: setShowApplyModal(true)

  • Horizontal ordering

    Arrange in one line the salary range from JobCard: salary.min - salary.max

  • Horizontal ordering

    Arrange in one line the info bar from JobCard: location • type • level-level

  • Code editor

    Job application modal

  • Vertical ordering

    Order the steps of handling the application submit (handleSubmit) in ApplyModal:

Useful articles