Vibe coding with AI course Β· Module 7: LinkedIn Clone
Job Board and Applications
In this lesson2
When there are dozens of offers, nobody scrolls through them all. A pirate from Tortuga wants to see only the contracts in his port, not every notice from across the Caribbean. We need filters, and then a form that sends the application together with a resume.
The job list with filters
A simplified offer (Job) has only what you see in the list. The array of offers and the filters live in the state of the JobBoard component:
interface Job {
id: number;
title: string;
company: string;
location: string;
salary: string;
type: 'full-time' | 'part-time' | 'contract';
posted: string;
}
function JobBoard() {
const [jobs, setJobs] = useState<Job[]>([]);
const [filters, setFilters] = useState({
location: '',
type: '',
salary: ''
});
const filteredJobs = jobs.filter(job => {
if (filters.location && !job.location.includes(filters.location)) return false;
if (filters.type && job.type !== filters.type) return false;
return true;
});
return (
<div>
<div className="mb-4 flex gap-4">
<input
onChange={(e) => setFilters({...filters, location: e.target.value})}
className="border rounded px-3 py-2"
/>
<select
onChange={(e) => setFilters({...filters, type: e.target.value as any})}
className="border rounded px-3 py-2"
>
<option value="">All Types</option>
<option value="full-time">Full Time</option>
<option value="part-time">Part Time</option>
<option value="contract">Contract</option>
</select>
</div>
<div className="space-y-4">
{filteredJobs.map(job => (
<div key={job.id} className="border rounded p-4">
<h3 className="font-bold text-lg">{job.title}</h3>
<p className="text-gray-600">{job.company} β’ {job.location}</p>
<p className="text-green-600 font-medium">{job.salary}</p>
<button className="mt-2 bg-blue-600 text-white px-4 py-2 rounded">
Apply Now
</button>
</div>
))}
</div>
</div>
);
}The type field accepts full-time, part-time or contract. We keep the filters in a single state object. setFilters({...filters, location: e.target.value}) copies the other filters with the ... operator and overwrites just one. filteredJobs comes from jobs.filter(), which checks location and type against the filters state, and empty filters let everything through. The original jobs array doesn't change. We compute the result on every render, so you don't need separate state or a useEffect for it.
The type list is a plain <select>: the option with an empty value means All Types, and the others match the values of the type field. The as any cast silences TypeScript, but a more precise type is better, e.g. Job['type'] | ''.
Two pitfalls. includes is case-sensitive, so "Tortuga" and "tortuga" are different to it, which is why you should compare text after toLowerCase(). The salary filter is already in state, but the list doesn't check it yet. The Apply Now button doesn't do anything yet either, and in a moment you'll hook the form with the offer id up to it.
The application form
The form collects a name, an email, a phone number, a resume and a cover letter, and then sends everything in one request:
function ApplicationForm({ jobId }: { jobId: number }) {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
resume: null as File | null,
coverLetter: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const data = new FormData();
Object.entries(formData).forEach(([key, value]) => {
if (value) data.append(key, value);
});
await fetch(`/api/jobs/${jobId}/apply`, {
method: 'POST',
body: data
});
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input
onChange={(e) => setFormData({...formData, name: e.target.value})}
required
className="w-full border rounded px-3 py-2"
/>
<input
type="email"
onChange={(e) => setFormData({...formData, email: e.target.value})}
required
className="w-full border rounded px-3 py-2"
/>
<input
type="file"
accept=".pdf,.doc,.docx"
onChange={(e) => setFormData({...formData, resume: e.target.files?.[0] || null})}
required
/>
<textarea
onChange={(e) => setFormData({...formData, coverLetter: e.target.value})}
rows={6}
className="w-full border rounded px-3 py-2"
/>
<button type="submit" className="bg-blue-600 text-white px-6 py-2 rounded">
Submit Application
</button>
</form>
);
}handleSubmit starts with e.preventDefault() to stop the page from reloading, then builds FormData from the formData fields with Object.entries, and finally calls fetch on /api/jobs/${jobId}/apply with the POST method and sends body: data. The address has three parts: /api/jobs/, ${jobId} and /apply. The if (value) condition skips empty fields and a missing file. Don't set the Content-Type header manually, because the browser adds multipart/form-data itself, together with the boundary that separates the fields. The form doesn't reset after sending and shows no confirmation, that's still ahead of you.
The required attribute and type="email" make the browser check the fields before sending, but the server still has to validate the data. The fields have no labels either. Add a <label> to each one, because without labels a screen reader can't say what a field expects.
Captain Redbeard's tip: don't store the filtered list in separate state. Always derive it from the full list, and one truth stays on board.
Remember: a filter is filter() on the full list, and an application with a file is FormData sent with fetch. In the editor below you have a job application form with a cover letter and a CV file, and the tasks will check job types, filtering and sending the form.
Code for this lesson: App.jsx
// Apply Modal - Job Application
import { useState } from 'react';
const job = {
title: 'First Mate',
company: 'Black Pearl Crew'
};
export default function App() {
const [showModal, setShowModal] = useState(false);
const [coverLetter, setCoverLetter] = useState('');
const [resumeFile, setResumeFile] = useState(null);
const [applied, setApplied] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
setApplied(true);
setShowModal(false);
};
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow p-8 text-center">
<h1 className="text-3xl font-bold mb-4">{job.title}</h1>
<p className="text-xl text-gray-700 mb-6">{job.company}</p>
<button
onClick={() => setShowModal(true)}
disabled={applied}
className={`px-8 py-3 rounded-full font-bold transition ${
applied
? 'bg-green-100 text-green-700 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{applied ? 'Application Sent' : 'Apply Now'}
</button>
</div>
{/* Modal */}
{showModal && (
<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}</h2>
<p className="text-gray-600">{job.title}</p>
</div>
<button
onClick={() => setShowModal(false)}
className="text-gray-500 hover:text-gray-700 text-3xl"
>
Γ
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Contact Info */}
<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"
required
/>
<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)}
placeholder="Why are you a great fit for this role?"
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>
{/* Buttons */}
<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={() => setShowModal(false)}
className="px-6 py-3 border-2 border-gray-300 rounded-full font-bold hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
</div>
</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. What values can the type field have in the Job interface (job board)?
2. How does JobBoard show only matching offers?
Hands-on tasks in the game
- Horizontal ordering
Arrange in one line the application submit path from ApplicationForm: /api/jobs/{jobId}/apply
- Vertical ordering
Order the steps of submitting the application form (handleSubmit) in ApplicationForm: