Cours Vibe coding avec l'IA · Module 7: Clone de LinkedIn
Offres d'emploi et candidatures - le recrutement
Dans cette leçon4
LinkedIn, ce ne sont pas que des profils, c'est aussi un marché de l'emploi. Le capitaine du Black Pearl cherche un second, et une annonce clouée à la porte de la taverne ne sera lue que par quelques passants. Construisons des offres que toute la flotte verra, et un formulaire pour s'engager à bord.
Le modèle d'offre et de candidature
L'offre et la candidature sont deux interfaces distinctes :
1interface JobPost {
2 id: string;
3 title: string; // "First Mate"
4 company: {
5 id: string;
6 name: string;
7 logo: string;
8 industry: string;
9 size: string; // "11-50 employees"
10 };
11 location: string;
12 type: 'full-time' | 'part-time' | 'contract' | 'temporary';
13 level: 'entry' | 'mid' | 'senior' | 'executive';
14 salary?: {
15 min: number;
16 max: number;
17 currency: string;
18 };
19 description: string;
20 requirements: string[];
21 responsibilities: string[];
22 skills: string[];
23 postedDate: Date;
24 applicants: number;
25 views: number;
26}
27
28interface Application {
29 id: string;
30 jobId: string;
31 applicant: {
32 id: string;
33 name: string;
34 avatar: string;
35 headline: string;
36 };
37 coverLetter: string;
38 resume?: string; // URL
39 status: 'pending' | 'reviewed' | 'interview' | 'rejected' | 'accepted';
40 appliedDate: Date;
41}JobPost a un titre, une entreprise, un lieu, des exigences, des responsabilités, des compétences et le nombre de candidats (applicants). Le champ type n'accepte que full-time, part-time, contract ou temporary, et level un niveau de entry à executive. Grâce à ces types union, TypeScript repère une faute de frappe. Le champ optionnel salary contient min, max et la devise (currency), et le point d'interrogation signale que toutes les offres n'affichent pas de fourchette. Application relie l'offre (jobId) au candidat, à la lettre de motivation et à l'adresse du CV, et suit la candidature via status : pending, reviewed, interview, rejected ou accepted. Les interfaces décrivent les données sans rien envoyer.
La carte d'offre
Dans la liste, chaque offre est une carte avec logo, titre, compétences et salaire :
1function JobCard({ job }: { job: JobPost }) {
2 const [isSaved, setIsSaved] = useState(false);
3
4 const timeAgo = (date: Date) => {
5 const days = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
6 if (days === 0) return 'Today';
7 if (days === 1) return 'Yesterday';
8 if (days < 7) return `${days} days ago`;
9 if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
10 return `${Math.floor(days / 30)} months ago`;
11 };
12
13 return (
14 <div className="bg-white border rounded-lg p-6 hover:shadow-lg transition-shadow cursor-pointer">
15 <div className="flex gap-4">
16 {/* Logo de l'entreprise */}
17 <img
18 src={job.company.logo}
19 alt={job.company.name}
20 className="w-16 h-16 rounded"
21 />
22
23 <div className="flex-1">
24 {/* Titre */}
25 <h3 className="text-xl font-bold text-blue-600 hover:underline mb-1">
26 {job.title}
27 </h3>
28
29 {/* Entreprise */}
30 <p className="text-gray-900 font-medium mb-1">{job.company.name}</p>
31
32 {/* Lieu & type */}
33 <p className="text-sm text-gray-600 mb-2">
34 {job.location} • {job.type.replace('-', ' ')} • {job.level}-level
35 </p>
36
37 {/* Salaire */}
38 {job.salary && (
39 <p className="text-sm text-gray-700 font-medium mb-2">
40 {job.salary.currency}{job.salary.min.toLocaleString()} -{' '}
41 {job.salary.currency}{job.salary.max.toLocaleString()}
42 </p>
43 )}
44
45 {/* Compétences */}
46 <div className="flex flex-wrap gap-2 mb-3">
47 {job.skills.slice(0, 3).map((skill, idx) => (
48 <span
49 key={idx}
50 className="px-2 py-1 bg-gray-100 rounded text-xs font-medium"
51 >
52 {skill}
53 </span>
54 ))}
55 {job.skills.length > 3 && (
56 <span className="px-2 py-1 text-xs text-gray-600">
57 +{job.skills.length - 3} more
58 </span>
59 )}
60 </div>
61
62 {/* Statistiques */}
63 <div className="flex items-center gap-4 text-xs text-gray-500">
64 <span>{job.applicants} applicants</span>
65 <span>•</span>
66 <span>{timeAgo(job.postedDate)}</span>
67 </div>
68 </div>
69
70 {/* Bouton Enregistrer */}
71 <button
72 onClick={(e) => {
73 e.stopPropagation();
74 setIsSaved(!isSaved);
75 }}
76 className="self-start text-sm font-semibold hover:scale-110 transition"
77 >
78 {isSaved ? 'Enregistré' : 'Enregistrer'}
79 </button>
80 </div>
81 </div>
82 );
83}La ligne d'infos prend la forme job.location • job.type • job.level-level, et replace('-', ' ') transforme full-time en full time. La fourchette est salary.min - salary.max : tu la lis via job.salary.min et job.salary.max, et la méthode toLocaleString() ajoute les séparateurs de milliers selon la langue du navigateur. Les trois premières compétences s'affichent, avec un compteur pour les autres. L'état isSaved retient si l'offre est sauvegardée pour plus tard, comme le bouton Save de LinkedIn, et e.stopPropagation() évite qu'un clic sur l'icône de sauvegarde ouvre toute l'offre.
timeAgo teste ses conditions de la plus petite différence de temps à la plus grande : days === 0 donne Today, days === 1 Yesterday, days < 7 un nombre de jours, days < 30 un nombre de semaines, puis des mois. L'ordre compte, car la première condition vraie l'emporte.
Le détail de l'offre
Après un clic sur la carte, le candidat arrive sur la page complète de l'offre :
1'use client';
2
3import { useState } from 'react';
4
5export default function JobDetailPage({ job }: { job: JobPost }) {
6 const [showApplyModal, setShowApplyModal] = useState(false);
7
8 return (
9 <div className="max-w-6xl mx-auto p-4">
10 <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
11 {/* Contenu principal */}
12 <div className="lg:col-span-2 space-y-4">
13 {/* En-tête */}
14 <div className="bg-white rounded-lg shadow p-6">
15 <div className="flex gap-4 mb-4">
16 <img
17 src={job.company.logo}
18 alt={job.company.name}
19 className="w-20 h-20 rounded"
20 />
21 <div className="flex-1">
22 <h1 className="text-3xl font-bold mb-2">{job.title}</h1>
23 <p className="text-lg text-gray-900 mb-1">{job.company.name}</p>
24 <p className="text-gray-600">
25 {job.location} • {job.type} • {job.level}
26 </p>
27 </div>
28 </div>
29
30 <div className="flex items-center gap-3 mb-4">
31 <button
32 onClick={() => setShowApplyModal(true)}
33 className="px-8 py-3 bg-blue-600 text-white rounded-full font-bold hover:bg-blue-700 transition"
34 >
35 Apply Now
36 </button>
37 <button className="px-6 py-3 border-2 border-blue-600 text-blue-600 rounded-full font-bold hover:bg-blue-50">
38 Save
39 </button>
40 </div>
41
42 <div className="flex items-center gap-4 text-sm text-gray-600">
43 <span>{job.applicants} applicants</span>
44 <span>•</span>
45 <span>{job.views} views</span>
46 <span>•</span>
47 <span>Posted {timeAgo(job.postedDate)}</span>
48 </div>
49 </div>
50
51 {/* Description */}
52 <div className="bg-white rounded-lg shadow p-6">
53 <h2 className="text-xl font-bold mb-3">About the job</h2>
54 <p className="text-gray-700 whitespace-pre-wrap mb-6">
55 {job.description}
56 </p>
57
58 <h3 className="font-bold text-lg mb-2">Responsibilities</h3>
59 <ul className="list-disc list-inside space-y-1 mb-6 text-gray-700">
60 {job.responsibilities.map((resp, idx) => (
61 <li key={idx}>{resp}</li>
62 ))}
63 </ul>
64
65 <h3 className="font-bold text-lg mb-2">Requirements</h3>
66 <ul className="list-disc list-inside space-y-1 text-gray-700">
67 {job.requirements.map((req, idx) => (
68 <li key={idx}>{req}</li>
69 ))}
70 </ul>
71 </div>
72 </div>
73
74 {/* Barre latérale */}
75 <div className="space-y-4">
76 {/* Infos entreprise */}
77 <div className="bg-white rounded-lg shadow p-6">
78 <h3 className="font-bold mb-3">About {job.company.name}</h3>
79 <p className="text-sm text-gray-600 mb-3">{job.company.industry}</p>
80 <p className="text-sm text-gray-600 mb-3">{job.company.size}</p>
81 <button className="w-full py-2 border-2 border-blue-600 text-blue-600 rounded-full font-semibold hover:bg-blue-50">
82 View Company Page
83 </button>
84 </div>
85
86 {/* Compétences requises */}
87 <div className="bg-white rounded-lg shadow p-6">
88 <h3 className="font-bold mb-3">Skills</h3>
89 <div className="flex flex-wrap gap-2">
90 {job.skills.map((skill, idx) => (
91 <span
92 key={idx}
93 className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium"
94 >
95 {skill}
96 </span>
97 ))}
98 </div>
99 </div>
100 </div>
101 </div>
102 </div>
103 );
104}La colonne principale contient la description, les responsabilités et les exigences, la colonne latérale les infos sur l'entreprise et les compétences requises. Le bouton Apply Now appelle setShowApplyModal(true). La page utilise aussi timeAgo, mais cette fonction vit dans JobCard : déplace-la dans un fichier partagé, par exemple utils.ts.
La fenêtre de candidature
Candidater se passe ainsi : clic sur Apply Now, ouverture d'ApplyModal, lettre de motivation et CV, puis envoi. Voici la fenêtre :
1'use client';
2
3import { useState } from 'react';
4
5interface ApplyModalProps {
6 job: JobPost;
7 onClose: () => void;
8 onSubmit: (application: Partial<Application>) => void;
9}
10
11export default function ApplyModal({ job, onClose, onSubmit }: ApplyModalProps) {
12 const [coverLetter, setCoverLetter] = useState('');
13 const [resumeFile, setResumeFile] = useState<File | null>(null);
14
15 const handleSubmit = (e: React.FormEvent) => {
16 e.preventDefault();
17
18 onSubmit({
19 jobId: job.id,
20 coverLetter,
21 status: 'pending',
22 appliedDate: new Date(),
23 });
24
25 onClose();
26 };
27
28 return (
29 <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
30 <div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
31 <div className="flex items-center justify-between p-6 border-b sticky top-0 bg-white">
32 <div>
33 <h2 className="text-2xl font-bold">Apply to {job.company.name}</h2>
34 <p className="text-gray-600">{job.title}</p>
35 </div>
36 <button
37 onClick={onClose}
38 className="text-gray-500 hover:text-gray-700 text-3xl"
39 >
40 ×
41 </button>
42 </div>
43
44 <form onSubmit={handleSubmit} className="p-6 space-y-6">
45 {/* Coordonnées (pré-remplies depuis le profil) */}
46 <div>
47 <h3 className="font-bold mb-3">Contact Information</h3>
48 <div className="bg-gray-50 p-4 rounded-lg">
49 <p className="text-sm text-gray-600">
50 Your profile information will be shared with the employer
51 </p>
52 </div>
53 </div>
54
55 {/* CV */}
56 <div>
57 <label className="block font-bold mb-2">Resume</label>
58 <input
59 type="file"
60 accept=".pdf,.doc,.docx"
61 onChange={(e) => setResumeFile(e.target.files?.[0] || null)}
62 className="w-full p-3 border rounded-lg"
63 />
64 <p className="text-xs text-gray-500 mt-1">
65 PDF, DOC, or DOCX (max 5MB)
66 </p>
67 </div>
68
69 {/* Lettre de motivation */}
70 <div>
71 <label className="block font-bold mb-2">
72 Cover Letter (Optional)
73 </label>
74 <textarea
75 value={coverLetter}
76 onChange={(e) => setCoverLetter(e.target.value)}
77 className="w-full p-3 border rounded-lg resize-none"
78 rows={8}
79 />
80 <p className="text-xs text-gray-500 mt-1">
81 {coverLetter.length} characters
82 </p>
83 </div>
84
85 {/* Envoyer */}
86 <div className="flex gap-3">
87 <button
88 type="submit"
89 className="flex-1 py-3 bg-blue-600 text-white rounded-full font-bold hover:bg-blue-700"
90 >
91 Submit Application
92 </button>
93 <button
94 type="button"
95 onClick={onClose}
96 className="px-6 py-3 border-2 border-gray-300 rounded-full font-bold hover:bg-gray-50"
97 >
98 Cancel
99 </button>
100 </div>
101 </form>
102 </div>
103 </div>
104 );
105}handleSubmit appelle d'abord e.preventDefault(), qui empêche le rechargement de la page, puis onSubmit avec jobId, coverLetter, status et appliedDate, et enfin onClose() ferme la fenêtre. Une nouvelle candidature reçoit le statut pending, car personne ne l'a encore examinée. Partial<Application> rend tous les champs optionnels, car le serveur complète le reste. Remarque que resumeFile va dans l'état, mais pas dans onSubmit. Tu enverras le CV avec FormData, comme la vidéo dans la leçon sur l'envoi de fichiers.
Conseil du Capitaine Barbe-Rouge : écris un ensemble fermé de valeurs, comme un type de contrat ou un statut, sous forme de type union plutôt que de simple string. Le compilateur tiendra le cap pour toi.
À retenir : une offre est une annonce avec fourchette et exigences, une candidature un dossier dont le statut commence à pending. Dans l'éditeur ci-dessous, tu as le tableau des offres de la flotte pirate avec ses cartes d'offres, et les exercices porteront sur les champs de l'offre et le parcours de candidature.
Code de cette leçon : App.jsx
1// Job Card - Liste des offres d'emploi
2import { useState } from 'react';
3
4const jobs = [
5 {
6 id: 1,
7 title: 'First Mate',
8 company: { name: 'Black Pearl Crew', logo: '' },
9 location: 'Caribbean Sea',
10 type: 'full-time',
11 level: 'mid',
12 salary: { min: 50000, max: 75000, currency: '$' },
13 skills: ['Navigation', 'Leadership', 'Combat', 'Cartography'],
14 applicants: 34,
15 postedDays: 2
16 },
17 {
18 id: 2,
19 title: 'Treasure Navigator',
20 company: { name: 'Flying Dutchman', logo: '' },
21 location: 'Atlantic Ocean',
22 type: 'contract',
23 level: 'senior',
24 salary: { min: 80000, max: 120000, currency: '$' },
25 skills: ['Map Reading', 'Treasure Detection', 'Diving'],
26 applicants: 67,
27 postedDays: 5
28 }
29];
30
31function JobCard({ job }) {
32 const [isSaved, setIsSaved] = useState(false);
33
34 return (
35 <div className="bg-white border rounded-lg p-6 hover:shadow-lg transition-shadow">
36 <div className="flex gap-4">
37 <div className="text-5xl">{job.company.logo}</div>
38
39 <div className="flex-1">
40 <h3 className="text-xl font-bold text-blue-600 hover:underline mb-1 cursor-pointer">
41 {job.title}
42 </h3>
43 <p className="text-gray-900 font-medium mb-1">{job.company.name}</p>
44 <p className="text-sm text-gray-600 mb-2">
45 {job.location} • {job.type} • {job.level}-level
46 </p>
47
48 {job.salary && (
49 <p className="text-sm text-gray-700 font-medium mb-2">
50 {job.salary.currency}{job.salary.min.toLocaleString()} - {job.salary.currency}{job.salary.max.toLocaleString()}
51 </p>
52 )}
53
54 <div className="flex flex-wrap gap-2 mb-3">
55 {job.skills.slice(0, 3).map((skill, idx) => (
56 <span key={idx} className="px-2 py-1 bg-gray-100 rounded text-xs font-medium">
57 {skill}
58 </span>
59 ))}
60 {job.skills.length > 3 && (
61 <span className="px-2 py-1 text-xs text-gray-600">+{job.skills.length - 3} more</span>
62 )}
63 </div>
64
65 <div className="flex items-center gap-4 text-xs text-gray-500">
66 <span>{job.applicants} applicants</span>
67 <span>•</span>
68 <span>{job.postedDays === 0 ? 'Today' : `${job.postedDays} days ago`}</span>
69 </div>
70 </div>
71
72 <button
73 onClick={() => setIsSaved(!isSaved)}
74 className="self-start text-sm font-semibold hover:scale-110 transition"
75 >
76 {isSaved ? 'Enregistré' : 'Enregistrer'}
77 </button>
78 </div>
79 </div>
80 );
81}
82
83export default function App() {
84 return (
85 <div className="min-h-screen bg-gray-100 p-8">
86 <div className="max-w-4xl mx-auto">
87 <h1 className="text-3xl font-bold mb-6">Pirate Job Board</h1>
88 <div className="space-y-4">
89 {jobs.map(job => (
90 <JobCard key={job.id} job={job} />
91 ))}
92 </div>
93 </div>
94 </div>
95 );
96}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que contient l'interface JobPost ?
2. Quelles valeurs le champ type peut-il avoir dans JobPost ?
Voici 2 des 6 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne les étapes de candidature à une offre d'emploi :
- Éditeur de code
Carte d'offre d'emploi
- Tri vertical
Ordonne les conditions de la fonction timeAgo de la plus petite à la plus grande différence de temps :
- Clique dans l'ordre
Ordonne la lecture du salaire minimum de l'offre : job.salary.min
- Clique dans l'ordre
Ordonne l'appel qui ouvre la fenêtre de candidature : setShowApplyModal(true)
- Tri horizontal
Ordonne sur une seule ligne la fourchette de salaire de JobCard : salary.min - salary.max
- Tri horizontal
Ordonne sur une seule ligne la barre d'infos de JobCard : location • type • level-level
- Éditeur de code
Fenêtre de candidature à une offre
- Tri vertical
Ordonne les étapes de la gestion de l'envoi de candidature (handleSubmit) dans ApplyModal :