Vibe coding with AI course · Module 7: LinkedIn Clone

Professional Profiles - CV and Experience

8 min read
In this lesson7

Welcome aboard! In harbor taverns, captains look for navigators and navigators look for good ships, but nobody knows who can do what. We'll build Crew Connections, a professional network for pirates modeled on LinkedIn, where a profile works like a seaman's book.

What you will learn

  • model a profile with experience, education and skills,
  • calculate time in a job from dates,
  • handle skill endorsements,
  • display recommendations.

LinkedIn vs other platforms

On Twitter, Instagram and YouTube you publish personal and entertaining content, and you follow people for fun. LinkedIn is about professional profiles, that is an online CV, business contacts, job offers, and endorsements and recommendations from colleagues. Instead of tweets and likes, experience is what counts here.

The profile model

A profile is made of several interfaces, and each one describes one part of a 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 has a headline, a short professional title such as "Senior Navigator at Black Pearl", plus an avatar, a banner and the number of connections (connections). On top of that come the experience, education, skills, endorsements and recommendations arrays. In Experience the endDate field is optional, and a missing end date means a job that is still ongoing. You won't find tweets, retweets or likes fields here, that's another world.

The profile header

From the top, ProfileHeader lays out the banner, the avatar, the name with the headline, the location with the connection count, and the About section:

'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>
  );
}

You read the number of connections from profile.connections. The Connect button toggles with setIsConnected(!isConnected) and changes its label to Connected. The About section appears only when the profile has a summary (profile.about && ...). The button changes state in the browser, but it doesn't send anything to the other person yet.

Experience and time in a job

Each position is a card with the company, the dates and the skills:

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 is true when endDate is missing, and then you see Present instead of an end date, e.g. formatDate(startDate) - Present. formatDate calls date.toLocaleDateString('en-US', ...), which produces something like "Mar 2023", while with 'pl-PL' you would get "mar 2023". calculateDuration counts the months between startDate and endDate (or today's date), and then splits them into years and months. From March 2023 to August 2025 is 29 months, that is "2 yrs 5 mos". Both functions only format dates, they don't change the profile data.

Skills and endorsements

An endorsement confirms another user's skill: with one click you vouch that someone really knows navigation. On LinkedIn, your direct connections do this. This is the skills section:

'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>
  );
}

Clicking + Endorse calls handleEndorse(skillId), which adds the id to the endorsedSkills set with setEndorsedSkills. From then on, the button shows Endorsed and is disabled (disabled). We create a new Set instead of adding to the old one, because React notices a state change only when it receives a new object.

Recommendations

A recommendation is something more: a written reference from a colleague or a manager. The RecommendationCard displays it:

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>
  );
}

From the top, the card shows the author's avatar, their name and headline, the relationship with the date, and below them the recommendation text. So the difference is simple: an endorsement confirms one skill, while a recommendation is a text about the whole collaboration. On LinkedIn a recommendation appears on the profile only after its recipient accepts it.

Captain Redbeard's tip: keep dates as Date objects and format them only when you display them. Switching the display language is then a single change in toLocaleDateString, not a rewrite of your data.

Remember: a profile is a seaman's book, an endorsement is a nod from the crew, and a recommendation is a letter of reference from the captain. In the editor below you have a finished profile header, and then the tasks are waiting for you.

Code for this lesson: App.jsx
// Professional Profile Header
import { useState } from 'react';

const profile = {
  name: 'Captain Jack Redbeard',
  headline: 'Senior Navigator & Treasure Hunter at Black Pearl Crew',
  avatar: '',
  banner: 'linear-gradient(to right, #1e3a8a, #0ea5e9)',
  location: 'Caribbean Sea',
  connections: 847,
  about: `Experienced navigator with 15+ years commanding vessels across the Seven Seas. Specialized in treasure hunting, naval strategy, and crew management.

Skills: Navigation, Cartography, Treasure Detection, Crew Leadership, Naval Combat`
};

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">
          {/* Banner */}
          <div
            className="h-48"
            style={{ background: profile.banner }}
          />

          {/* Profile Info */}
          <div className="px-6 pb-6">
            <div className="flex items-start gap-6 -mt-20">
              {/* Avatar */}
              <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} 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>
                  </div>
                </div>

                {/* 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>
      </div>
    </div>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Which field is NOT part of the Profile interface in LinkedIn?

  2. 2. How do you calculate duration between dates in Experience?

These are 2 of 6 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Professional profile header

  • Vertical ordering

    Order the ProfileHeader elements as they appear from top to bottom:

  • Click in order

    Arrange the date formatting call (formatDate): date.toLocaleDateString('en-US')

  • Click in order

    Arrange reading the connections count from the profile: profile.connections

  • Click in order

    Arrange the call that toggles the Connect button: setIsConnected(!isConnected)

  • Vertical ordering

    Order the steps of confirming a skill (handleEndorse) in SkillsSection:

  • Code editor

    Skills section component

  • Horizontal ordering

    Arrange in one line the date fragment from ExperienceCard: formatDate(startDate) - Present

  • Vertical ordering

    Order the RecommendationCard elements from top to bottom:

Useful articles