Kurs vibe coding z AI · Moduł 7: LinkedIn Clone
Oferty pracy i aplikacje - rekrutacja
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. Co zawiera JobPost interface?
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: