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

Oferty pracy i aplikacje - rekrutacja

7 min czytania
W tej lekcji4

LinkedIn to nie tylko profile, ale też giełda pracy. Kapitan Czarnej Perły szuka pierwszego oficera, a ogłoszenie przybite do drzwi tawerny przeczyta najwyżej kilku przechodniów. Zbudujemy oferty, które zobaczy cała flota, i formularz zgłoszenia na pokład.

Model oferty i aplikacji

Oferta i zgłoszenie to dwa osobne interfejsy:

interface JobPost {
  id: string;
  title: string; // "First Mate"
  company: {
    id: string;
    name: string;
    logo: string;
    industry: string;
    size: string; // "11-50 employees"
  };
  location: string;
  type: 'full-time' | 'part-time' | 'contract' | 'temporary';
  level: 'entry' | 'mid' | 'senior' | 'executive';
  salary?: {
    min: number;
    max: number;
    currency: string;
  };
  description: string;
  requirements: string[];
  responsibilities: string[];
  skills: string[];
  postedDate: Date;
  applicants: number;
  views: number;
}

interface Application {
  id: string;
  jobId: string;
  applicant: {
    id: string;
    name: string;
    avatar: string;
    headline: string;
  };
  coverLetter: string;
  resume?: string; // URL
  status: 'pending' | 'reviewed' | 'interview' | 'rejected' | 'accepted';
  appliedDate: Date;
}

JobPost ma tytuł, firmę, lokalizację, wymagania, obowiązki, umiejętności i liczbę aplikujących (applicants). Pole type przyjmuje tylko wartości full-time, part-time, contract albo temporary, a level stopień od entry do executive. Dzięki takim typom unii TypeScript wyłapie literówkę. Opcjonalne salary zawiera min, max i walutę (currency), a znak zapytania oznacza, że nie każda oferta podaje widełki. Application łączy ofertę (jobId) z kandydatem, listem motywacyjnym i adresem CV, a zgłoszenie śledzi przez status: pending, reviewed, interview, rejected albo accepted. Interfejsy tylko opisują dane, niczego jeszcze nie wysyłają.

Karta oferty

Na liście każda oferta to karta z logo, tytułem, umiejętnościami i wynagrodzeniem:

function JobCard({ job }: { job: JobPost }) {
  const [isSaved, setIsSaved] = useState(false);

  const timeAgo = (date: Date) => {
    const days = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
    if (days === 0) return 'Today';
    if (days === 1) return 'Yesterday';
    if (days < 7) return `${days} days ago`;
    if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
    return `${Math.floor(days / 30)} months ago`;
  };

  return (
    <div className="bg-white border rounded-lg p-6 hover:shadow-lg transition-shadow cursor-pointer">
      <div className="flex gap-4">
        {/* Company Logo */}
        <img
          src={job.company.logo}
          alt={job.company.name}
          className="w-16 h-16 rounded"
        />

        <div className="flex-1">
          {/* Title */}
          <h3 className="text-xl font-bold text-blue-600 hover:underline mb-1">
            {job.title}
          </h3>

          {/* Company */}
          <p className="text-gray-900 font-medium mb-1">{job.company.name}</p>

          {/* Location & Type */}
          <p className="text-sm text-gray-600 mb-2">
            {job.location} • {job.type.replace('-', ' ')} • {job.level}-level
          </p>

          {/* Salary */}
          {job.salary && (
            <p className="text-sm text-gray-700 font-medium mb-2">
              {job.salary.currency}{job.salary.min.toLocaleString()} -{' '}
              {job.salary.currency}{job.salary.max.toLocaleString()}
            </p>
          )}

          {/* Skills */}
          <div className="flex flex-wrap gap-2 mb-3">
            {job.skills.slice(0, 3).map((skill, idx) => (
              <span
                key={idx}
                className="px-2 py-1 bg-gray-100 rounded text-xs font-medium"
              >
                {skill}
              </span>
            ))}
            {job.skills.length > 3 && (
              <span className="px-2 py-1 text-xs text-gray-600">
                +{job.skills.length - 3} more
              </span>
            )}
          </div>

          {/* Stats */}
          <div className="flex items-center gap-4 text-xs text-gray-500">
            <span>{job.applicants} applicants</span>
            <span>•</span>
            <span>{timeAgo(job.postedDate)}</span>
          </div>
        </div>

        {/* Save Button */}
        <button
          onClick={(e) => {
            e.stopPropagation();
            setIsSaved(!isSaved);
          }}
          className="self-start text-sm font-semibold hover:scale-110 transition"
        >
          {isSaved ? 'Zapisane' : 'Zapisz'}
        </button>
      </div>
    </div>
  );
}

Wiersz informacji ma postać job.location • job.type • job.level-level, a replace('-', ' ') zamienia full-time na full time. Widełki to salary.min - salary.max: odczytujesz je przez job.salary.min oraz job.salary.max, a metoda toLocaleString() dodaje separatory tysięcy zgodne z językiem przeglądarki. Widać trzy pierwsze umiejętności i licznik pozostałych. Stan isSaved zapamiętuje, czy oferta jest zapisana na później, jak przycisk Save w LinkedInie, a e.stopPropagation() sprawia, że kliknięcie ikony zapisu nie otwiera całej oferty.

timeAgo sprawdza warunki od najmniejszej różnicy czasu: days === 0 daje Today, days === 1 Yesterday, days < 7 liczbę dni, days < 30 liczbę tygodni, a dalej miesiące. Kolejność ma znaczenie, bo wygrywa pierwszy spełniony warunek.

Szczegóły oferty

Po kliknięciu karty kandydat trafia na pełną stronę oferty:

'use client';

import { useState } from 'react';

export default function JobDetailPage({ job }: { job: JobPost }) {
  const [showApplyModal, setShowApplyModal] = useState(false);

  return (
    <div className="max-w-6xl mx-auto p-4">
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
        {/* Main Content */}
        <div className="lg:col-span-2 space-y-4">
          {/* Header */}
          <div className="bg-white rounded-lg shadow p-6">
            <div className="flex gap-4 mb-4">
              <img
                src={job.company.logo}
                alt={job.company.name}
                className="w-20 h-20 rounded"
              />
              <div className="flex-1">
                <h1 className="text-3xl font-bold mb-2">{job.title}</h1>
                <p className="text-lg text-gray-900 mb-1">{job.company.name}</p>
                <p className="text-gray-600">
                  {job.location} • {job.type} • {job.level}
                </p>
              </div>
            </div>

            <div className="flex items-center gap-3 mb-4">
              <button
                onClick={() => setShowApplyModal(true)}
                className="px-8 py-3 bg-blue-600 text-white rounded-full font-bold hover:bg-blue-700 transition"
              >
                Apply Now
              </button>
              <button className="px-6 py-3 border-2 border-blue-600 text-blue-600 rounded-full font-bold hover:bg-blue-50">
                Save
              </button>
            </div>

            <div className="flex items-center gap-4 text-sm text-gray-600">
              <span>{job.applicants} applicants</span>
              <span>•</span>
              <span>{job.views} views</span>
              <span>•</span>
              <span>Posted {timeAgo(job.postedDate)}</span>
            </div>
          </div>

          {/* Description */}
          <div className="bg-white rounded-lg shadow p-6">
            <h2 className="text-xl font-bold mb-3">About the job</h2>
            <p className="text-gray-700 whitespace-pre-wrap mb-6">
              {job.description}
            </p>

            <h3 className="font-bold text-lg mb-2">Responsibilities</h3>
            <ul className="list-disc list-inside space-y-1 mb-6 text-gray-700">
              {job.responsibilities.map((resp, idx) => (
                <li key={idx}>{resp}</li>
              ))}
            </ul>

            <h3 className="font-bold text-lg mb-2">Requirements</h3>
            <ul className="list-disc list-inside space-y-1 text-gray-700">
              {job.requirements.map((req, idx) => (
                <li key={idx}>{req}</li>
              ))}
            </ul>
          </div>
        </div>

        {/* Sidebar */}
        <div className="space-y-4">
          {/* Company Info */}
          <div className="bg-white rounded-lg shadow p-6">
            <h3 className="font-bold mb-3">About {job.company.name}</h3>
            <p className="text-sm text-gray-600 mb-3">{job.company.industry}</p>
            <p className="text-sm text-gray-600 mb-3">{job.company.size}</p>
            <button className="w-full py-2 border-2 border-blue-600 text-blue-600 rounded-full font-semibold hover:bg-blue-50">
              View Company Page
            </button>
          </div>

          {/* Required Skills */}
          <div className="bg-white rounded-lg shadow p-6">
            <h3 className="font-bold mb-3">Skills</h3>
            <div className="flex flex-wrap gap-2">
              {job.skills.map((skill, idx) => (
                <span
                  key={idx}
                  className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium"
                >
                  {skill}
                </span>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

Główna kolumna zawiera opis, obowiązki i wymagania, a boczna informacje o firmie i potrzebne umiejętności. Przycisk Apply Now wywołuje setShowApplyModal(true). Strona korzysta też z timeAgo, ale ta funkcja żyje wewnątrz JobCard, więc przenieś ją do wspólnego pliku, np. utils.ts.

Okno aplikacji

Zgłoszenie przebiega tak: kliknięcie Apply Now, otwarcie ApplyModal, wypełnienie listu motywacyjnego (cover letter) i dodanie CV, a na końcu wysłanie. Samo okno wygląda tak:

'use client';

import { useState } from 'react';

interface ApplyModalProps {
  job: JobPost;
  onClose: () => void;
  onSubmit: (application: Partial<Application>) => void;
}

export default function ApplyModal({ job, onClose, onSubmit }: ApplyModalProps) {
  const [coverLetter, setCoverLetter] = useState('');
  const [resumeFile, setResumeFile] = useState<File | null>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    onSubmit({
      jobId: job.id,
      coverLetter,
      status: 'pending',
      appliedDate: new Date(),
    });

    onClose();
  };

  return (
    <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
      <div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
        <div className="flex items-center justify-between p-6 border-b sticky top-0 bg-white">
          <div>
            <h2 className="text-2xl font-bold">Apply to {job.company.name}</h2>
            <p className="text-gray-600">{job.title}</p>
          </div>
          <button
            onClick={onClose}
            className="text-gray-500 hover:text-gray-700 text-3xl"
          >
            ×
          </button>
        </div>

        <form onSubmit={handleSubmit} className="p-6 space-y-6">
          {/* Contact Info (pre-filled from profile) */}
          <div>
            <h3 className="font-bold mb-3">Contact Information</h3>
            <div className="bg-gray-50 p-4 rounded-lg">
              <p className="text-sm text-gray-600">
                Your profile information will be shared with the employer
              </p>
            </div>
          </div>

          {/* Resume */}
          <div>
            <label className="block font-bold mb-2">Resume</label>
            <input
              type="file"
              accept=".pdf,.doc,.docx"
              onChange={(e) => setResumeFile(e.target.files?.[0] || null)}
              className="w-full p-3 border rounded-lg"
            />
            <p className="text-xs text-gray-500 mt-1">
              PDF, DOC, or DOCX (max 5MB)
            </p>
          </div>

          {/* Cover Letter */}
          <div>
            <label className="block font-bold mb-2">
              Cover Letter (Optional)
            </label>
            <textarea
              value={coverLetter}
              onChange={(e) => setCoverLetter(e.target.value)}
              className="w-full p-3 border rounded-lg resize-none"
              rows={8}
            />
            <p className="text-xs text-gray-500 mt-1">
              {coverLetter.length} characters
            </p>
          </div>

          {/* Submit */}
          <div className="flex gap-3">
            <button
              type="submit"
              className="flex-1 py-3 bg-blue-600 text-white rounded-full font-bold hover:bg-blue-700"
            >
              Submit Application
            </button>
            <button
              type="button"
              onClick={onClose}
              className="px-6 py-3 border-2 border-gray-300 rounded-full font-bold hover:bg-gray-50"
            >
              Cancel
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

handleSubmit najpierw wywołuje e.preventDefault(), które zatrzymuje przeładowanie strony, potem onSubmit z jobId, coverLetter, status i appliedDate, a na końcu onClose() zamyka okno. Nowe zgłoszenie dostaje status pending, bo nikt go jeszcze nie przejrzał. Partial<Application> sprawia, że wszystkie pola są opcjonalne, bo resztę uzupełni serwer. Zwróć uwagę, że resumeFile trafia do stanu, ale nie do onSubmit. Plik CV wyślesz przez FormData, tak jak film w lekcji o przesyłaniu plików.

Rada Kapitana Redbearda: zamknięty zbiór wartości, taki jak typ umowy czy status, zapisuj jako typ unii, a nie zwykły string. Kompilator będzie wtedy pilnował kursu za Ciebie.

Zapamiętaj: oferta to ogłoszenie z widełkami i wymaganiami, a aplikacja to zgłoszenie ze statusem, który zaczyna się od pending. W edytorze poniżej masz tablicę ofert pirackiej floty z kartami ofert, a zadania sprawdzą pola oferty i przebieg aplikacji.

Kod do tej lekcji: App.jsx
// Karta oferty - lista ofert pracy
import { useState } from 'react';

const jobs = [
  {
    id: 1,
    title: 'Pierwszy oficer',
    company: { name: 'Załoga Czarnej Perły', logo: '' },
    location: 'Morze Karaibskie',
    type: 'full-time',
    level: 'mid',
    salary: { min: 50000, max: 75000, currency: '$' },
    skills: ['Nawigacja', 'Przywództwo', 'Walka', 'Kartografia'],
    applicants: 34,
    postedDays: 2
  },
  {
    id: 2,
    title: 'Nawigator wypraw po skarby',
    company: { name: 'Latający Holender', logo: '' },
    location: 'Ocean Atlantycki',
    type: 'contract',
    level: 'senior',
    salary: { min: 80000, max: 120000, currency: '$' },
    skills: ['Czytanie map', 'Wykrywanie skarbów', 'Nurkowanie'],
    applicants: 67,
    postedDays: 5
  }
];

function JobCard({ job }) {
  const [isSaved, setIsSaved] = useState(false);

  return (
    <div className="bg-white border rounded-lg p-6 hover:shadow-lg transition-shadow">
      <div className="flex gap-4">
        <div className="text-5xl">{job.company.logo}</div>

        <div className="flex-1">
          <h3 className="text-xl font-bold text-blue-600 hover:underline mb-1 cursor-pointer">
            {job.title}
          </h3>
          <p className="text-gray-900 font-medium mb-1">{job.company.name}</p>
          <p className="text-sm text-gray-600 mb-2">
            {job.location} • {job.type} • poziom {job.level}
          </p>

          {job.salary && (
            <p className="text-sm text-gray-700 font-medium mb-2">
              {job.salary.currency}{job.salary.min.toLocaleString()} - {job.salary.currency}{job.salary.max.toLocaleString()}
            </p>
          )}

          <div className="flex flex-wrap gap-2 mb-3">
            {job.skills.slice(0, 3).map((skill, idx) => (
              <span key={idx} className="px-2 py-1 bg-gray-100 rounded text-xs font-medium">
                {skill}
              </span>
            ))}
            {job.skills.length > 3 && (
              <span className="px-2 py-1 text-xs text-gray-600">+{job.skills.length - 3} więcej</span>
            )}
          </div>

          <div className="flex items-center gap-4 text-xs text-gray-500">
            <span>{job.applicants} kandydatów</span>
            <span>•</span>
            <span>{job.postedDays === 0 ? 'Dzisiaj' : `${job.postedDays} dni temu`}</span>
          </div>
        </div>

        <button
          onClick={() => setIsSaved(!isSaved)}
          className="self-start text-sm font-semibold hover:scale-110 transition"
        >
          {isSaved ? 'Zapisane' : 'Zapisz'}
        </button>
      </div>
    </div>
  );
}

export default function App() {
  return (
    <div className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold mb-6">Piracka tablica ofert</h1>
        <div className="space-y-4">
          {jobs.map(job => (
            <JobCard key={job.id} job={job} />
          ))}
        </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. Co zawiera JobPost interface?

  2. 2. Jakie wartości może mieć pole type w JobPost?

To 2 z 6 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż kroki aplikowania na job post:

  • Edytor kodu

    Job listing card

  • Układanie w pionie

    Uporządkuj warunki w funkcji timeAgo od najmniejszej różnicy czasu do największej:

  • Klikanie w kolejności

    Ułóż odczyt minimalnego wynagrodzenia z oferty: job.salary.min

  • Klikanie w kolejności

    Ułóż wywołanie otwierające modal aplikacji: setShowApplyModal(true)

  • Układanie w poziomie

    Ułóż w jednej linii zakres wynagrodzenia z JobCard: salary.min - salary.max

  • Układanie w poziomie

    Ułóż w jednej linii pasek informacji z JobCard: location • type • level-level

  • Edytor kodu

    Job application modal

  • Układanie w pionie

    Uporządkuj kroki obsługi wysyłki aplikacji (handleSubmit) w ApplyModal:

Przydatne artykuły