Cours Vibe coding avec l'IA · Module 7: Clone de LinkedIn

Job board et candidatures

5 min de lecture
Dans cette leçon2

Quand il y a des dizaines d'offres, personne ne les fait toutes défiler. Un pirate de Tortuga veut voir les contrats de son port, pas toutes les annonces des Caraïbes. Il nous faut des filtres, puis un formulaire qui envoie la candidature avec un CV.

La liste des offres avec filtres

Une offre simplifiée (Job) ne contient que ce qu'on voit dans la liste. Le tableau des offres et les filtres vivent dans l'état du composant JobBoard :

1interface Job {
2  id: number;
3  title: string;
4  company: string;
5  location: string;
6  salary: string;
7  type: 'full-time' | 'part-time' | 'contract';
8  posted: string;
9}
10
11function JobBoard() {
12  const [jobs, setJobs] = useState<Job[]>([]);
13  const [filters, setFilters] = useState({
14    location: '',
15    type: '',
16    salary: ''
17  });
18
19  const filteredJobs = jobs.filter(job => {
20    if (filters.location && !job.location.includes(filters.location)) return false;
21    if (filters.type && job.type !== filters.type) return false;
22    return true;
23  });
24
25  return (
26    <div>
27      <div className="mb-4 flex gap-4">
28        <input
29          onChange={(e) => setFilters({...filters, location: e.target.value})}
30          className="border rounded px-3 py-2"
31        />
32        <select
33          onChange={(e) => setFilters({...filters, type: e.target.value as any})}
34          className="border rounded px-3 py-2"
35        >
36          <option value="">All Types</option>
37          <option value="full-time">Full Time</option>
38          <option value="part-time">Part Time</option>
39          <option value="contract">Contract</option>
40        </select>
41      </div>
42
43      <div className="space-y-4">
44        {filteredJobs.map(job => (
45          <div key={job.id} className="border rounded p-4">
46            <h3 className="font-bold text-lg">{job.title}</h3>
47            <p className="text-gray-600">{job.company} • {job.location}</p>
48            <p className="text-green-600 font-medium">{job.salary}</p>
49            <button className="mt-2 bg-blue-600 text-white px-4 py-2 rounded">
50              Apply Now
51            </button>
52          </div>
53        ))}
54      </div>
55    </div>
56  );
57}

Le champ type accepte full-time, part-time ou contract. On garde les filtres dans un seul objet d'état. setFilters({...filters, location: e.target.value}) copie les autres filtres avec l'opérateur ... et n'en remplace qu'un. filteredJobs vient de jobs.filter(), qui vérifie location et type par rapport à l'état filters, et des filtres vides laissent tout passer. Le tableau d'origine jobs ne change pas. Le résultat se calcule à chaque rendu : pas besoin d'état séparé ni de useEffect.

La liste des types est un simple <select> : l'option à valeur vide signifie All Types, les autres correspondent aux valeurs du champ type. Le cast as any fait taire TypeScript, mais un type plus précis vaut mieux, par exemple Job['type'] | ''.

Deux pièges. includes est sensible à la casse : « Tortuga » et « tortuga » sont différents pour lui, donc compare les textes après toLowerCase(). Le filtre de salaire est déjà dans l'état, mais la liste ne l'utilise pas encore. Le bouton Apply Now ne fait rien non plus pour l'instant : tu vas bientôt y brancher le formulaire avec l'identifiant de l'offre.

Le formulaire de candidature

Le formulaire recueille un nom, un e-mail, un téléphone, un CV et une lettre de motivation, puis envoie le tout en une requête :

1function ApplicationForm({ jobId }: { jobId: number }) {
2  const [formData, setFormData] = useState({
3    name: '',
4    email: '',
5    phone: '',
6    resume: null as File | null,
7    coverLetter: ''
8  });
9
10  const handleSubmit = async (e: React.FormEvent) => {
11    e.preventDefault();
12    const data = new FormData();
13    Object.entries(formData).forEach(([key, value]) => {
14      if (value) data.append(key, value);
15    });
16
17    await fetch(`/api/jobs/${jobId}/apply`, {
18      method: 'POST',
19      body: data
20    });
21  };
22
23  return (
24    <form onSubmit={handleSubmit} className="space-y-4">
25      <input
26        onChange={(e) => setFormData({...formData, name: e.target.value})}
27        required
28        className="w-full border rounded px-3 py-2"
29      />
30      <input
31        type="email"
32        onChange={(e) => setFormData({...formData, email: e.target.value})}
33        required
34        className="w-full border rounded px-3 py-2"
35      />
36      <input
37        type="file"
38        accept=".pdf,.doc,.docx"
39        onChange={(e) => setFormData({...formData, resume: e.target.files?.[0] || null})}
40        required
41      />
42      <textarea
43        onChange={(e) => setFormData({...formData, coverLetter: e.target.value})}
44        rows={6}
45        className="w-full border rounded px-3 py-2"
46      />
47      <button type="submit" className="bg-blue-600 text-white px-6 py-2 rounded">
48        Submit Application
49      </button>
50    </form>
51  );
52}

handleSubmit commence par e.preventDefault(), qui empêche le rechargement de la page, construit ensuite un FormData à partir des champs de formData avec Object.entries, puis appelle fetch vers /api/jobs/${jobId}/apply avec la méthode POST et envoie body: data. L'adresse a trois parties : /api/jobs/, ${jobId} et /apply. La condition if (value) ignore les champs vides et l'absence de fichier. Ne définis pas l'en-tête Content-Type à la main : le navigateur ajoute lui-même multipart/form-data avec la frontière qui sépare les champs. Le formulaire ne se vide pas après l'envoi et n'affiche aucune confirmation : c'est la suite pour toi.

L'attribut required et type="email" font vérifier les champs par le navigateur avant l'envoi, mais le serveur doit quand même valider les données. Les champs n'ont pas non plus d'étiquettes. Ajoute un <label> à chacun, car sans étiquette un lecteur d'écran ne peut pas dire ce que le champ attend.

Conseil du Capitaine Barbe-Rouge : ne stocke pas la liste filtrée dans un état séparé. Calcule-la toujours à partir de la liste complète, et une seule vérité reste à bord.

À retenir : un filtre, c'est filter() sur la liste complète, et une candidature avec fichier, c'est un FormData envoyé par fetch. Dans l'éditeur ci-dessous, tu as un formulaire de candidature avec une lettre de motivation et un fichier de CV, et les exercices porteront sur les types d'offres, le filtrage et l'envoi du formulaire.

Code de cette leçon : App.jsx
1// Apply Modal - Candidature à un emploi
2import { useState } from 'react';
3
4const job = {
5  title: 'First Mate',
6  company: 'Black Pearl Crew'
7};
8
9export default function App() {
10  const [showModal, setShowModal] = useState(false);
11  const [coverLetter, setCoverLetter] = useState('');
12  const [resumeFile, setResumeFile] = useState(null);
13  const [applied, setApplied] = useState(false);
14
15  const handleSubmit = (e) => {
16    e.preventDefault();
17    setApplied(true);
18    setShowModal(false);
19  };
20
21  return (
22    <div className="min-h-screen bg-gray-100 p-8">
23      <div className="max-w-4xl mx-auto">
24        <div className="bg-white rounded-lg shadow p-8 text-center">
25          <h1 className="text-3xl font-bold mb-4">{job.title}</h1>
26          <p className="text-xl text-gray-700 mb-6">{job.company}</p>
27
28          <button
29            onClick={() => setShowModal(true)}
30            disabled={applied}
31            className={`px-8 py-3 rounded-full font-bold transition ${
32              applied
33                ? 'bg-green-100 text-green-700 cursor-not-allowed'
34                : 'bg-blue-600 text-white hover:bg-blue-700'
35            }`}
36          >
37            {applied ? 'Application Sent' : 'Apply Now'}
38          </button>
39        </div>
40
41        {/* Modal */}
42        {showModal && (
43          <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
44            <div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
45              <div className="flex items-center justify-between p-6 border-b sticky top-0 bg-white">
46                <div>
47                  <h2 className="text-2xl font-bold">Apply to {job.company}</h2>
48                  <p className="text-gray-600">{job.title}</p>
49                </div>
50                <button
51                  onClick={() => setShowModal(false)}
52                  className="text-gray-500 hover:text-gray-700 text-3xl"
53                >
54                  ×
55                </button>
56              </div>
57
58              <form onSubmit={handleSubmit} className="p-6 space-y-6">
59                {/* Coordonnées */}
60                <div>
61                  <h3 className="font-bold mb-3">Contact Information</h3>
62                  <div className="bg-gray-50 p-4 rounded-lg">
63                    <p className="text-sm text-gray-600">
64                      Your profile information will be shared with the employer
65                    </p>
66                  </div>
67                </div>
68
69                {/* CV */}
70                <div>
71                  <label className="block font-bold mb-2">Resume *</label>
72                  <input
73                    type="file"
74                    accept=".pdf,.doc,.docx"
75                    onChange={(e) => setResumeFile(e.target.files?.[0] || null)}
76                    className="w-full p-3 border rounded-lg"
77                    required
78                  />
79                  <p className="text-xs text-gray-500 mt-1">PDF, DOC, or DOCX (max 5MB)</p>
80                </div>
81
82                {/* Lettre de motivation */}
83                <div>
84                  <label className="block font-bold mb-2">Cover Letter (Optional)</label>
85                  <textarea
86                    value={coverLetter}
87                    onChange={(e) => setCoverLetter(e.target.value)}
88                    placeholder="Why are you a great fit for this role?"
89                    className="w-full p-3 border rounded-lg resize-none"
90                    rows={8}
91                  />
92                  <p className="text-xs text-gray-500 mt-1">{coverLetter.length} characters</p>
93                </div>
94
95                {/* Boutons */}
96                <div className="flex gap-3">
97                  <button
98                    type="submit"
99                    className="flex-1 py-3 bg-blue-600 text-white rounded-full font-bold hover:bg-blue-700"
100                  >
101                    Submit Application
102                  </button>
103                  <button
104                    type="button"
105                    onClick={() => setShowModal(false)}
106                    className="px-6 py-3 border-2 border-gray-300 rounded-full font-bold hover:bg-gray-50"
107                  >
108                    Cancel
109                  </button>
110                </div>
111              </form>
112            </div>
113          </div>
114        )}
115      </div>
116    </div>
117  );
118}

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. 1. Quelles valeurs le champ type peut-il avoir dans l'interface Job (offres d'emploi) ?

  2. 2. Comment JobBoard n'affiche-t-il que les offres correspondantes ?

Tâches pratiques dans le jeu

  • Tri horizontal

    Ordonne sur une seule ligne le chemin d'envoi de la candidature depuis ApplicationForm : /api/jobs/{jobId}/apply

  • Tri vertical

    Ordonne les étapes d'envoi du formulaire de candidature (handleSubmit) dans ApplicationForm :

Articles utiles