Kurs vibe coding z AI · Moduł 7: LinkedIn Clone

Profile zawodowe - CV i doświadczenie

7 min czytania
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. 1. Które pole NIE jest częścią Profile interface w LinkedIn?

  2. 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:

Przydatne artykuły