Kurs vibe coding z AI · Moduł 7: LinkedIn Clone
Profile zawodowe - CV i doświadczenie
W tej lekcji7
Witaj! W portowych tawernach kapitanowie szukają nawigatorów, a nawigatorzy dobrych statków, tylko nikt nie wie, kto co potrafi. Zbudujemy Crew Connections, zawodową sieć piratów na wzór LinkedIna, w której profil działa jak książeczka żeglarska.
Czego się nauczysz
- modelować profil z doświadczeniem, wykształceniem i umiejętnościami,
- liczyć staż pracy na podstawie dat,
- obsługiwać potwierdzenia umiejętności (endorsements),
- wyświetlać rekomendacje.
LinkedIn a inne platformy
Na Twitterze, Instagramie i YouTubie publikujesz treści osobiste i rozrywkowe, a obserwujesz dla przyjemności. LinkedIn to profesjonalne profile, czyli CV online, kontakty biznesowe, oferty pracy oraz potwierdzenia i rekomendacje od współpracowników. Zamiast tweetów i polubień liczy się tu doświadczenie.
Model profilu
Profil składa się z kilku interfejsów, a każdy opisuje jedną część CV:
interface Profile {
id: string;
name: string;
headline: string; // "Senior Navigator at Black Pearl"
avatar: string;
banner: string;
location: string;
connections: number;
about: string; // Professional summary
experience: Experience[];
education: Education[];
skills: Skill[];
endorsements: Endorsement[];
recommendations: Recommendation[];
}
interface Experience {
id: string;
title: string; // "First Mate"
company: string; // "Black Pearl Crew"
companyLogo?: string;
location: string;
startDate: Date;
endDate?: Date; // null = currently working
description: string;
skills: string[];
}
interface Education {
id: string;
school: string;
degree: string; // "Master of Navigation"
field: string; // "Cartography & Maritime Studies"
startDate: Date;
endDate: Date;
description?: string;
}
interface Skill {
id: string;
name: string;
endorsements: number;
}
interface Endorsement {
skillId: string;
endorsedBy: {
id: string;
name: string;
avatar: string;
};
date: Date;
}
interface Recommendation {
id: string;
author: {
id: string;
name: string;
avatar: string;
headline: string;
};
relationship: string; // "worked together at..."
text: string;
date: Date;
}Profile ma headline, czyli krótki tytuł zawodowy w rodzaju „Senior Navigator at Black Pearl”, a obok awatar, baner i liczbę kontaktów (connections). Do tego dochodzą tablice experience, education, skills, endorsements i recommendations. W Experience pole endDate jest opcjonalne, a brak daty końca oznacza pracę, która wciąż trwa. Pól tweets, retweets ani likes tu nie znajdziesz, to inny świat.
Nagłówek profilu
ProfileHeader układa od góry baner, awatar, imię z headline, lokalizację z liczbą kontaktów i sekcję About:
'use client';
import { useState } from 'react';
export default function ProfileHeader({ profile }: { profile: Profile }) {
const [isConnected, setIsConnected] = useState(false);
const [showContact, setShowContact] = useState(false);
return (
<div className="bg-white rounded-lg shadow mb-4 overflow-hidden">
{/* Banner */}
<div
className="h-48 bg-gradient-to-r from-blue-600 to-cyan-600"
style={{
backgroundImage: `url(${profile.banner})`,
backgroundSize: 'cover',
}}
/>
{/* Profile Info */}
<div className="px-6 pb-6">
<div className="flex items-start gap-6 -mt-20">
{/* Avatar */}
<img
src={profile.avatar}
alt={profile.name}
className="w-40 h-40 rounded-full border-4 border-white shadow-lg"
/>
<div className="flex-1 mt-20">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold mb-1">{profile.name}</h1>
<p className="text-xl text-gray-700 mb-2">{profile.headline}</p>
<div className="flex items-center gap-3 text-gray-600 text-sm mb-3">
<span>{profile.location}</span>
<span>•</span>
<button className="text-blue-600 hover:underline">
{profile.connections} connections
</button>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => setIsConnected(!isConnected)}
className={`px-6 py-2 rounded-full font-semibold transition ${
isConnected
? 'border-2 border-blue-600 text-blue-600'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isConnected ? 'Connected' : '+ Connect'}
</button>
<button className="px-6 py-2 border-2 border-gray-300 rounded-full font-semibold hover:border-gray-400">
Message
</button>
<button
onClick={() => setShowContact(true)}
className="px-6 py-2 border-2 border-gray-300 rounded-full font-semibold hover:border-gray-400"
>
More
</button>
</div>
</div>
{/* About Section */}
{profile.about && (
<div className="mt-4">
<h2 className="font-bold text-lg mb-2">About</h2>
<p className="text-gray-700 whitespace-pre-wrap">{profile.about}</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}Liczbę kontaktów odczytujesz z profile.connections. Przycisk Connect przełącza się przez setIsConnected(!isConnected) i zmienia napis na Connected. Sekcja About pojawia się tylko wtedy, gdy profil ma podsumowanie (profile.about && ...). Przycisk zmienia stan w przeglądarce, ale niczego jeszcze nie wysyła drugiej osobie.
Doświadczenie i staż
Każde stanowisko to karta z firmą, datami i umiejętnościami:
function ExperienceCard({ experience }: { experience: Experience }) {
const isCurrentJob = !experience.endDate;
const formatDate = (date: Date) => {
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
};
const calculateDuration = (start: Date, end?: Date) => {
const endDate = end || new Date();
const months = (endDate.getFullYear() - start.getFullYear()) * 12 +
(endDate.getMonth() - start.getMonth());
const years = Math.floor(months / 12);
const remainingMonths = months % 12;
if (years === 0) return `${remainingMonths} mos`;
if (remainingMonths === 0) return `${years} yrs`;
return `${years} yrs ${remainingMonths} mos`;
};
return (
<div className="flex gap-4 py-4 border-b last:border-0">
{/* Company Logo */}
{experience.companyLogo ? (
<img
src={experience.companyLogo}
alt={experience.company}
className="w-12 h-12 rounded"
/>
) : (
<div className="w-12 h-12 bg-gray-200 rounded flex items-center justify-center text-gray-500 text-xl">
◆
</div>
)}
<div className="flex-1">
<h3 className="font-bold text-lg">{experience.title}</h3>
<p className="text-gray-700">{experience.company}</p>
<p className="text-sm text-gray-600 mb-2">
{formatDate(experience.startDate)} -{' '}
{isCurrentJob ? 'Present' : formatDate(experience.endDate!)} •{' '}
{calculateDuration(experience.startDate, experience.endDate)}
</p>
<p className="text-sm text-gray-600 mb-2">{experience.location}</p>
{experience.description && (
<p className="text-gray-700 text-sm mb-3 whitespace-pre-wrap">
{experience.description}
</p>
)}
{/* Skills */}
{experience.skills.length > 0 && (
<div className="flex flex-wrap gap-2">
{experience.skills.map((skill, idx) => (
<span
key={idx}
className="px-3 py-1 bg-gray-100 rounded-full text-sm font-medium"
>
{skill}
</span>
))}
</div>
)}
</div>
</div>
);
}
export default function ExperienceSection({ experiences }: { experiences: Experience[] }) {
return (
<div className="bg-white rounded-lg shadow p-6 mb-4">
<h2 className="text-2xl font-bold mb-4">Experience</h2>
<div>
{experiences.map(exp => (
<ExperienceCard key={exp.id} experience={exp} />
))}
</div>
</div>
);
}isCurrentJob jest prawdą, gdy brakuje endDate, i wtedy zamiast daty końca widać Present, np. formatDate(startDate) - Present. formatDate wywołuje date.toLocaleDateString('en-US', ...), co daje zapis w rodzaju „Mar 2023”, a z 'pl-PL' dostaniesz „mar 2023”. calculateDuration liczy miesiące między startDate a endDate (albo dzisiejszą datą), a potem dzieli je na lata i miesiące. Od marca 2023 do sierpnia 2025 to 29 miesięcy, czyli „2 yrs 5 mos”. Obie funkcje tylko formatują daty, danych profilu nie zmieniają.
Umiejętności i potwierdzenia
Endorsement to potwierdzenie umiejętności innego użytkownika: jednym kliknięciem ręczysz, że ktoś naprawdę zna nawigację. W LinkedInie robią to bezpośrednie kontakty. Tak wygląda sekcja umiejętności:
'use client';
import { useState } from 'react';
interface SkillItemProps {
skill: Skill;
onEndorse: (skillId: string) => void;
isEndorsed: boolean;
}
function SkillItem({ skill, onEndorse, isEndorsed }: SkillItemProps) {
return (
<div className="flex items-center justify-between py-3 border-b">
<div className="flex-1">
<h3 className="font-semibold">{skill.name}</h3>
<p className="text-sm text-gray-600">
{skill.endorsements} endorsements
</p>
</div>
<button
onClick={() => onEndorse(skill.id)}
disabled={isEndorsed}
className={`px-4 py-2 rounded-full font-semibold transition ${
isEndorsed
? 'bg-gray-100 text-gray-500 cursor-not-allowed'
: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50'
}`}
>
{isEndorsed ? 'Endorsed' : '+ Endorse'}
</button>
</div>
);
}
export default function SkillsSection({
skills,
onEndorse
}: {
skills: Skill[];
onEndorse: (skillId: string) => void;
}) {
const [endorsedSkills, setEndorsedSkills] = useState<Set<string>>(new Set());
const handleEndorse = (skillId: string) => {
setEndorsedSkills(new Set([...endorsedSkills, skillId]));
onEndorse(skillId);
};
return (
<div className="bg-white rounded-lg shadow p-6 mb-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Skills</h2>
<button className="text-blue-600 hover:underline font-semibold">
+ Add skill
</button>
</div>
<div>
{skills.map(skill => (
<SkillItem
key={skill.id}
skill={skill}
onEndorse={handleEndorse}
isEndorsed={endorsedSkills.has(skill.id)}
/>
))}
</div>
</div>
);
}Kliknięcie + Endorse wywołuje handleEndorse(skillId), które dodaje identyfikator do zbioru endorsedSkills przez setEndorsedSkills. Od tej chwili przycisk pokazuje Endorsed i jest wyłączony (disabled). Tworzymy nowy Set zamiast dopisywać do starego, bo React zauważa zmianę stanu tylko wtedy, gdy dostanie nowy obiekt.
Rekomendacje
Rekomendacja to coś więcej: pisemna referencja od współpracownika albo przełożonego. Wyświetla ją karta RecommendationCard:
function RecommendationCard({ recommendation }: { recommendation: Recommendation }) {
return (
<div className="py-4 border-b last:border-0">
<div className="flex gap-3 mb-3">
<img
src={recommendation.author.avatar}
alt={recommendation.author.name}
className="w-12 h-12 rounded-full"
/>
<div>
<h3 className="font-bold">{recommendation.author.name}</h3>
<p className="text-sm text-gray-600">{recommendation.author.headline}</p>
<p className="text-xs text-gray-500 mt-1">
{recommendation.relationship} •{' '}
{recommendation.date.toLocaleDateString('en-US', {
month: 'long',
year: 'numeric'
})}
</p>
</div>
</div>
<p className="text-gray-700 italic">" {recommendation.text} "</p>
</div>
);
}
export default function RecommendationsSection({
recommendations
}: {
recommendations: Recommendation[];
}) {
return (
<div className="bg-white rounded-lg shadow p-6 mb-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">
Recommendations
<span className="text-gray-600 font-normal text-lg ml-2">
({recommendations.length})
</span>
</h2>
<button className="text-blue-600 hover:underline font-semibold">
Ask for recommendation
</button>
</div>
<div>
{recommendations.map(rec => (
<RecommendationCard key={rec.id} recommendation={rec} />
))}
</div>
</div>
);
}Karta pokazuje od góry awatar autora, jego imię i headline, relację z datą, a pod spodem tekst rekomendacji. Różnica jest więc prosta: endorsement potwierdza jedną umiejętność, a recommendation to tekst o całej współpracy. W LinkedInie rekomendacja trafia na profil dopiero wtedy, gdy jej adresat ją zaakceptuje.
Rada Kapitana Redbearda: daty trzymaj jako obiekty Date, a formatuj je dopiero przy wyświetlaniu. Przejście z angielskiego na polski to wtedy jedna zmiana w toLocaleDateString, a nie przepisywanie danych.
Zapamiętaj: profil to książeczka żeglarska, endorsement to skinienie głowy załogi, a rekomendacja to list polecający od kapitana. W edytorze poniżej masz gotowy nagłówek profilu, a potem czekają na Ciebie zadania.
Kod do tej lekcji: App.jsx
// Nagłówek profilu zawodowego
import { useState } from 'react';
const profile = {
name: 'Kapitan Jack Redbeard',
headline: 'Starszy nawigator i łowca skarbów w załodze Czarnej Perły',
avatar: '',
banner: 'linear-gradient(to right, #1e3a8a, #0ea5e9)',
location: 'Morze Karaibskie',
connections: 847,
about: `Doświadczony nawigator, od ponad 15 lat dowodzi statkami na siedmiu morzach. Specjalizacja: poszukiwanie skarbów, strategia morska i zarządzanie załogą.
Umiejętności: nawigacja, kartografia, wykrywanie skarbów, dowodzenie załogą, walka morska`
};
export default function App() {
const [isConnected, setIsConnected] = useState(false);
return (
<div className="min-h-screen bg-gray-100 p-4">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow overflow-hidden">
{/* Baner */}
<div
className="h-48"
style={{ background: profile.banner }}
/>
{/* Dane profilu */}
<div className="px-6 pb-6">
<div className="flex items-start gap-6 -mt-20">
{/* Awatar */}
<div className="text-8xl bg-white rounded-full p-4 shadow-lg border-4 border-white">
{profile.avatar}
</div>
<div className="flex-1 mt-20">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold mb-1">{profile.name}</h1>
<p className="text-xl text-gray-700 mb-2">{profile.headline}</p>
<div className="flex items-center gap-3 text-gray-600 text-sm mb-3">
<span>{profile.location}</span>
<span>•</span>
<button className="text-blue-600 hover:underline">
{profile.connections} kontaktów
</button>
</div>
</div>
{/* Przyciski akcji */}
<div className="flex items-center gap-2">
<button
onClick={() => setIsConnected(!isConnected)}
className={`px-6 py-2 rounded-full font-semibold transition ${
isConnected
? 'border-2 border-blue-600 text-blue-600'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isConnected ? 'Połączono' : '+ Połącz'}
</button>
<button className="px-6 py-2 border-2 border-gray-300 rounded-full font-semibold hover:border-gray-400">
Wiadomość
</button>
</div>
</div>
{/* O mnie */}
<div className="mt-4">
<h2 className="font-bold text-lg mb-2">O mnie</h2>
<p className="text-gray-700 whitespace-pre-wrap">{profile.about}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Które pole NIE jest częścią Profile interface w LinkedIn?
2. Jak obliczyć duration między datami w Experience?
To 2 z 6 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Professional profile header
- Układanie w pionie
Uporządkuj elementy ProfileHeader tak, jak wyświetlają się od góry do dołu:
- Klikanie w kolejności
Ułóż wywołanie formatowania daty (formatDate): date.toLocaleDateString('en-US')
- Klikanie w kolejności
Ułóż odczyt liczby połączeń z profilu: profile.connections
- Klikanie w kolejności
Ułóż wywołanie przełączające Connect button: setIsConnected(!isConnected)
- Układanie w pionie
Uporządkuj kroki potwierdzania umiejętności (handleEndorse) w SkillsSection:
- Edytor kodu
Skills section component
- Układanie w poziomie
Ułóż w jednej linii fragment daty z ExperienceCard: formatDate(startDate) - Present
- Układanie w pionie
Uporządkuj elementy RecommendationCard od góry do dołu: