Cours Vibe coding avec l'IA · Module 7: Clone de LinkedIn
Profils professionnels - CV et expérience
Dans cette leçon7
Bienvenue à bord ! Dans les tavernes du port, les capitaines cherchent des navigateurs et les navigateurs de bons navires, mais personne ne sait qui sait faire quoi. Construisons Connexions d'équipage, un réseau professionnel de pirates sur le modèle de LinkedIn, où le profil sert de livret de marin.
Au programme
- modéliser un profil avec expérience, formation et compétences,
- calculer l'ancienneté à partir de dates,
- gérer les validations de compétences (endorsements),
- afficher des recommandations.
LinkedIn face aux autres plateformes
Sur Twitter, Instagram et YouTube, tu publies du contenu personnel et divertissant, et tu suis des gens pour le plaisir. LinkedIn, ce sont des profils professionnels (un CV en ligne), des contacts d'affaires, des offres d'emploi, des validations et des recommandations de collègues. Ici, l'expérience compte plus que les tweets et les likes.
Le modèle de profil
Un profil se compose de plusieurs interfaces, chacune décrivant une partie du CV :
1interface Profile {
2 id: string;
3 name: string;
4 headline: string; // "Senior Navigator at Black Pearl"
5 avatar: string;
6 banner: string;
7 location: string;
8 connections: number;
9
10 about: string; // Résumé professionnel
11
12 experience: Experience[];
13 education: Education[];
14 skills: Skill[];
15
16 endorsements: Endorsement[];
17 recommendations: Recommendation[];
18}
19
20interface Experience {
21 id: string;
22 title: string; // "First Mate"
23 company: string; // "Black Pearl Crew"
24 companyLogo?: string;
25 location: string;
26 startDate: Date;
27 endDate?: Date; // null = poste actuel
28 description: string;
29 skills: string[];
30}
31
32interface Education {
33 id: string;
34 school: string;
35 degree: string; // "Master of Navigation"
36 field: string; // "Cartography & Maritime Studies"
37 startDate: Date;
38 endDate: Date;
39 description?: string;
40}
41
42interface Skill {
43 id: string;
44 name: string;
45 endorsements: number;
46}
47
48interface Endorsement {
49 skillId: string;
50 endorsedBy: {
51 id: string;
52 name: string;
53 avatar: string;
54 };
55 date: Date;
56}
57
58interface Recommendation {
59 id: string;
60 author: {
61 id: string;
62 name: string;
63 avatar: string;
64 headline: string;
65 };
66 relationship: string; // "worked together at..."
67 text: string;
68 date: Date;
69}Profile a un headline, un titre professionnel court comme « Senior Navigator at Black Pearl », ainsi qu'un avatar, une bannière et le nombre de relations (connections). S'y ajoutent les tableaux experience, education, skills, endorsements et recommendations. Dans Experience, le champ endDate est optionnel : sans date de fin, le poste est toujours en cours. Pas de champs tweets, retweets ni likes ici, c'est un autre monde.
L'en-tête du profil
De haut en bas, ProfileHeader affiche la bannière, l'avatar, le nom avec le headline, le lieu avec le nombre de relations, puis la section À propos :
1'use client';
2
3import { useState } from 'react';
4
5export default function ProfileHeader({ profile }: { profile: Profile }) {
6 const [isConnected, setIsConnected] = useState(false);
7 const [showContact, setShowContact] = useState(false);
8
9 return (
10 <div className="bg-white rounded-lg shadow mb-4 overflow-hidden">
11 {/* Bannière */}
12 <div
13 className="h-48 bg-gradient-to-r from-blue-600 to-cyan-600"
14 style={{
15 backgroundImage: `url(${profile.banner})`,
16 backgroundSize: 'cover',
17 }}
18 />
19
20 {/* Infos du profil */}
21 <div className="px-6 pb-6">
22 <div className="flex items-start gap-6 -mt-20">
23 {/* Avatar */}
24 <img
25 src={profile.avatar}
26 alt={profile.name}
27 className="w-40 h-40 rounded-full border-4 border-white shadow-lg"
28 />
29
30 <div className="flex-1 mt-20">
31 <div className="flex items-start justify-between">
32 <div>
33 <h1 className="text-3xl font-bold mb-1">{profile.name}</h1>
34 <p className="text-xl text-gray-700 mb-2">{profile.headline}</p>
35 <div className="flex items-center gap-3 text-gray-600 text-sm mb-3">
36 <span>{profile.location}</span>
37 <span>•</span>
38 <button className="text-blue-600 hover:underline">
39 {profile.connections} connections
40 </button>
41 </div>
42 </div>
43
44 {/* Boutons d'action */}
45 <div className="flex items-center gap-2">
46 <button
47 onClick={() => setIsConnected(!isConnected)}
48 className={`px-6 py-2 rounded-full font-semibold transition ${
49 isConnected
50 ? 'border-2 border-blue-600 text-blue-600'
51 : 'bg-blue-600 text-white hover:bg-blue-700'
52 }`}
53 >
54 {isConnected ? 'Connecté' : '+ Se connecter'}
55 </button>
56 <button className="px-6 py-2 border-2 border-gray-300 rounded-full font-semibold hover:border-gray-400">
57 Message
58 </button>
59 <button
60 onClick={() => setShowContact(true)}
61 className="px-6 py-2 border-2 border-gray-300 rounded-full font-semibold hover:border-gray-400"
62 >
63 Plus
64 </button>
65 </div>
66 </div>
67
68 {/* Section À propos */}
69 {profile.about && (
70 <div className="mt-4">
71 <h2 className="font-bold text-lg mb-2">À propos</h2>
72 <p className="text-gray-700 whitespace-pre-wrap">{profile.about}</p>
73 </div>
74 )}
75 </div>
76 </div>
77 </div>
78 </div>
79 );
80}Le nombre de relations se lit dans profile.connections. Le bouton Se connecter bascule avec setIsConnected(!isConnected) et son libellé devient Connecté. La section À propos n'apparaît que si le profil a un résumé (profile.about && ...). Le bouton change l'état dans le navigateur, mais n'envoie encore rien à l'autre personne.
Expérience et ancienneté
Chaque poste est une carte avec l'entreprise, les dates et les compétences :
1function ExperienceCard({ experience }: { experience: Experience }) {
2 const isCurrentJob = !experience.endDate;
3
4 const formatDate = (date: Date) => {
5 return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
6 };
7
8 const calculateDuration = (start: Date, end?: Date) => {
9 const endDate = end || new Date();
10 const months = (endDate.getFullYear() - start.getFullYear()) * 12 +
11 (endDate.getMonth() - start.getMonth());
12 const years = Math.floor(months / 12);
13 const remainingMonths = months % 12;
14
15 if (years === 0) return `${remainingMonths} mos`;
16 if (remainingMonths === 0) return `${years} yrs`;
17 return `${years} yrs ${remainingMonths} mos`;
18 };
19
20 return (
21 <div className="flex gap-4 py-4 border-b last:border-0">
22 {/* Logo de l'entreprise */}
23 {experience.companyLogo ? (
24 <img
25 src={experience.companyLogo}
26 alt={experience.company}
27 className="w-12 h-12 rounded"
28 />
29 ) : (
30 <div className="w-12 h-12 bg-gray-200 rounded flex items-center justify-center text-gray-500 text-xl">
31 ◆
32 </div>
33 )}
34
35 <div className="flex-1">
36 <h3 className="font-bold text-lg">{experience.title}</h3>
37 <p className="text-gray-700">{experience.company}</p>
38 <p className="text-sm text-gray-600 mb-2">
39 {formatDate(experience.startDate)} -{' '}
40 {isCurrentJob ? 'Present' : formatDate(experience.endDate!)} •{' '}
41 {calculateDuration(experience.startDate, experience.endDate)}
42 </p>
43 <p className="text-sm text-gray-600 mb-2">{experience.location}</p>
44
45 {experience.description && (
46 <p className="text-gray-700 text-sm mb-3 whitespace-pre-wrap">
47 {experience.description}
48 </p>
49 )}
50
51 {/* Compétences */}
52 {experience.skills.length > 0 && (
53 <div className="flex flex-wrap gap-2">
54 {experience.skills.map((skill, idx) => (
55 <span
56 key={idx}
57 className="px-3 py-1 bg-gray-100 rounded-full text-sm font-medium"
58 >
59 {skill}
60 </span>
61 ))}
62 </div>
63 )}
64 </div>
65 </div>
66 );
67}
68
69export default function ExperienceSection({ experiences }: { experiences: Experience[] }) {
70 return (
71 <div className="bg-white rounded-lg shadow p-6 mb-4">
72 <h2 className="text-2xl font-bold mb-4">Expérience</h2>
73 <div>
74 {experiences.map(exp => (
75 <ExperienceCard key={exp.id} experience={exp} />
76 ))}
77 </div>
78 </div>
79 );
80}isCurrentJob est vrai quand endDate manque, et on voit alors Present (aujourd'hui) au lieu d'une date de fin, par exemple formatDate(startDate) - Present. formatDate appelle date.toLocaleDateString('en-US', ...), ce qui donne « Mar 2023 », alors qu'avec 'fr-FR' tu obtiendrais « mars 2023 ». calculateDuration compte les mois entre startDate et endDate (ou aujourd'hui), puis les répartit en années et en mois. De mars 2023 à août 2025, cela fait 29 mois, soit « 2 yrs 5 mos ». Ces deux fonctions formatent les dates sans toucher aux données du profil.
Compétences et validations
Une validation de compétence (endorsement) confirme la compétence d'un autre utilisateur : d'un clic, tu garantis que quelqu'un connaît vraiment la navigation. Sur LinkedIn, ce sont tes relations directes qui le font. Voici la section des compétences :
1'use client';
2
3import { useState } from 'react';
4
5interface SkillItemProps {
6 skill: Skill;
7 onEndorse: (skillId: string) => void;
8 isEndorsed: boolean;
9}
10
11function SkillItem({ skill, onEndorse, isEndorsed }: SkillItemProps) {
12 return (
13 <div className="flex items-center justify-between py-3 border-b">
14 <div className="flex-1">
15 <h3 className="font-semibold">{skill.name}</h3>
16 <p className="text-sm text-gray-600">
17 {skill.endorsements} endorsements
18 </p>
19 </div>
20
21 <button
22 onClick={() => onEndorse(skill.id)}
23 disabled={isEndorsed}
24 className={`px-4 py-2 rounded-full font-semibold transition ${
25 isEndorsed
26 ? 'bg-gray-100 text-gray-500 cursor-not-allowed'
27 : 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50'
28 }`}
29 >
30 {isEndorsed ? 'Recommandé' : '+ Recommander'}
31 </button>
32 </div>
33 );
34}
35
36export default function SkillsSection({
37 skills,
38 onEndorse
39}: {
40 skills: Skill[];
41 onEndorse: (skillId: string) => void;
42}) {
43 const [endorsedSkills, setEndorsedSkills] = useState<Set<string>>(new Set());
44
45 const handleEndorse = (skillId: string) => {
46 setEndorsedSkills(new Set([...endorsedSkills, skillId]));
47 onEndorse(skillId);
48 };
49
50 return (
51 <div className="bg-white rounded-lg shadow p-6 mb-4">
52 <div className="flex items-center justify-between mb-4">
53 <h2 className="text-2xl font-bold">Compétences</h2>
54 <button className="text-blue-600 hover:underline font-semibold">
55 + Ajouter une compétence
56 </button>
57 </div>
58
59 <div>
60 {skills.map(skill => (
61 <SkillItem
62 key={skill.id}
63 skill={skill}
64 onEndorse={handleEndorse}
65 isEndorsed={endorsedSkills.has(skill.id)}
66 />
67 ))}
68 </div>
69 </div>
70 );
71}Un clic sur + Recommander (valider la compétence) appelle handleEndorse(skillId), qui ajoute l'identifiant à l'ensemble endorsedSkills via setEndorsedSkills. Dès lors, le bouton affiche Recommandé (compétence validée) et devient désactivé (disabled). On crée un nouveau Set au lieu de compléter l'ancien, car React ne remarque un changement d'état que s'il reçoit un nouvel objet.
Les recommandations
Une recommandation va plus loin : c'est une référence écrite d'un collègue ou d'un supérieur. La carte RecommendationCard l'affiche :
1function RecommendationCard({ recommendation }: { recommendation: Recommendation }) {
2 return (
3 <div className="py-4 border-b last:border-0">
4 <div className="flex gap-3 mb-3">
5 <img
6 src={recommendation.author.avatar}
7 alt={recommendation.author.name}
8 className="w-12 h-12 rounded-full"
9 />
10 <div>
11 <h3 className="font-bold">{recommendation.author.name}</h3>
12 <p className="text-sm text-gray-600">{recommendation.author.headline}</p>
13 <p className="text-xs text-gray-500 mt-1">
14 {recommendation.relationship} •{' '}
15 {recommendation.date.toLocaleDateString('en-US', {
16 month: 'long',
17 year: 'numeric'
18 })}
19 </p>
20 </div>
21 </div>
22
23 <p className="text-gray-700 italic">" {recommendation.text} "</p>
24 </div>
25 );
26}
27
28export default function RecommendationsSection({
29 recommendations
30}: {
31 recommendations: Recommendation[];
32}) {
33 return (
34 <div className="bg-white rounded-lg shadow p-6 mb-4">
35 <div className="flex items-center justify-between mb-4">
36 <h2 className="text-2xl font-bold">
37 Recommandations
38 <span className="text-gray-600 font-normal text-lg ml-2">
39 ({recommendations.length})
40 </span>
41 </h2>
42 <button className="text-blue-600 hover:underline font-semibold">
43 Demander une recommandation
44 </button>
45 </div>
46
47 <div>
48 {recommendations.map(rec => (
49 <RecommendationCard key={rec.id} recommendation={rec} />
50 ))}
51 </div>
52 </div>
53 );
54}De haut en bas, la carte montre l'avatar de l'auteur, son nom et son headline, la relation avec la date, puis le texte de la recommandation. La différence est simple : la validation confirme une seule compétence, la recommandation raconte toute une collaboration. Sur LinkedIn, une recommandation n'apparaît sur le profil qu'après acceptation par son destinataire.
Conseil du Capitaine Barbe-Rouge : garde les dates sous forme d'objets Date et formate-les seulement à l'affichage. Passer de l'anglais au français se résume alors à un changement dans toLocaleDateString, sans réécrire les données.
À retenir : le profil est un livret de marin, la validation un signe de tête de l'équipage, la recommandation une lettre du capitaine. Dans l'éditeur ci-dessous, tu as un en-tête de profil complet, puis les exercices t'attendent.
Code de cette leçon : App.jsx
1// En-tête de profil professionnel
2import { useState } from 'react';
3
4const profile = {
5 name: 'Captain Jack Redbeard',
6 headline: 'Senior Navigator & Treasure Hunter at Black Pearl Crew',
7 avatar: '',
8 banner: 'linear-gradient(to right, #1e3a8a, #0ea5e9)',
9 location: 'Caribbean Sea',
10 connections: 847,
11 about: `Experienced navigator with 15+ years commanding vessels across the Seven Seas. Specialized in treasure hunting, naval strategy, and crew management.
12
13Skills: Navigation, Cartography, Treasure Detection, Crew Leadership, Naval Combat`
14};
15
16export default function App() {
17 const [isConnected, setIsConnected] = useState(false);
18
19 return (
20 <div className="min-h-screen bg-gray-100 p-4">
21 <div className="max-w-4xl mx-auto">
22 <div className="bg-white rounded-lg shadow overflow-hidden">
23 {/* Bannière */}
24 <div
25 className="h-48"
26 style={{ background: profile.banner }}
27 />
28
29 {/* Infos du profil */}
30 <div className="px-6 pb-6">
31 <div className="flex items-start gap-6 -mt-20">
32 {/* Avatar */}
33 <div className="text-8xl bg-white rounded-full p-4 shadow-lg border-4 border-white">
34 {profile.avatar}
35 </div>
36
37 <div className="flex-1 mt-20">
38 <div className="flex items-start justify-between">
39 <div>
40 <h1 className="text-3xl font-bold mb-1">{profile.name}</h1>
41 <p className="text-xl text-gray-700 mb-2">{profile.headline}</p>
42 <div className="flex items-center gap-3 text-gray-600 text-sm mb-3">
43 <span>{profile.location}</span>
44 <span>•</span>
45 <button className="text-blue-600 hover:underline">
46 {profile.connections} connections
47 </button>
48 </div>
49 </div>
50
51 {/* Boutons d'action */}
52 <div className="flex items-center gap-2">
53 <button
54 onClick={() => setIsConnected(!isConnected)}
55 className={`px-6 py-2 rounded-full font-semibold transition ${
56 isConnected
57 ? 'border-2 border-blue-600 text-blue-600'
58 : 'bg-blue-600 text-white hover:bg-blue-700'
59 }`}
60 >
61 {isConnected ? 'Connected' : '+ Connect'}
62 </button>
63 <button className="px-6 py-2 border-2 border-gray-300 rounded-full font-semibold hover:border-gray-400">
64 Message
65 </button>
66 </div>
67 </div>
68
69 {/* À propos */}
70 <div className="mt-4">
71 <h2 className="font-bold text-lg mb-2">About</h2>
72 <p className="text-gray-700 whitespace-pre-wrap">{profile.about}</p>
73 </div>
74 </div>
75 </div>
76 </div>
77 </div>
78 </div>
79 </div>
80 );
81}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. Quel champ ne fait PAS partie de l'interface Profile dans LinkedIn ?
2. Comment calcule-t-on la durée entre deux dates dans Experience ?
Voici 2 des 6 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
En-tête de profil professionnel
- Tri vertical
Ordonne les éléments de ProfileHeader tels qu'ils apparaissent de haut en bas :
- Clique dans l'ordre
Ordonne l'appel de formatage de date (formatDate) : date.toLocaleDateString('en-US')
- Clique dans l'ordre
Ordonne la lecture du nombre de relations depuis le profil : profile.connections
- Clique dans l'ordre
Ordonne l'appel qui bascule le bouton Se connecter : setIsConnected(!isConnected)
- Tri vertical
Ordonne les étapes de validation d'une compétence (handleEndorse) dans SkillsSection :
- Éditeur de code
Composant section Compétences
- Tri horizontal
Ordonne sur une seule ligne le fragment de date de ExperienceCard : formatDate(startDate) - Aujourd'hui
- Tri vertical
Ordonne les éléments de RecommendationCard de haut en bas :