Kurs vibe coding z AI · Moduł 7: LinkedIn Clone
Tablica ofert i aplikacje
W tej lekcji2
Gdy ofert jest kilkadziesiąt, nikt nie przewija ich wszystkich. Pirat z Tortugi chce zobaczyć tylko kontrakty w swoim porcie, a nie każde ogłoszenie z całych Karaibów. Potrzebujemy filtrów, a potem formularza, który wyśle zgłoszenie razem z CV.
Lista ofert z filtrami
Uproszczona oferta (Job) ma tylko to, co widać na liście. Tablica ofert i filtry żyją w stanie komponentu JobBoard:
interface Job {
id: number;
title: string;
company: string;
location: string;
salary: string;
type: 'full-time' | 'part-time' | 'contract';
posted: string;
}
function JobBoard() {
const [jobs, setJobs] = useState<Job[]>([]);
const [filters, setFilters] = useState({
location: '',
type: '',
salary: ''
});
const filteredJobs = jobs.filter(job => {
if (filters.location && !job.location.includes(filters.location)) return false;
if (filters.type && job.type !== filters.type) return false;
return true;
});
return (
<div>
<div className="mb-4 flex gap-4">
<input
onChange={(e) => setFilters({...filters, location: e.target.value})}
className="border rounded px-3 py-2"
/>
<select
onChange={(e) => setFilters({...filters, type: e.target.value as any})}
className="border rounded px-3 py-2"
>
<option value="">All Types</option>
<option value="full-time">Full Time</option>
<option value="part-time">Part Time</option>
<option value="contract">Contract</option>
</select>
</div>
<div className="space-y-4">
{filteredJobs.map(job => (
<div key={job.id} className="border rounded p-4">
<h3 className="font-bold text-lg">{job.title}</h3>
<p className="text-gray-600">{job.company} • {job.location}</p>
<p className="text-green-600 font-medium">{job.salary}</p>
<button className="mt-2 bg-blue-600 text-white px-4 py-2 rounded">
Apply Now
</button>
</div>
))}
</div>
</div>
);
}Pole type przyjmuje wartości full-time, part-time albo contract. Filtry trzymamy w jednym obiekcie stanu. setFilters({...filters, location: e.target.value}) kopiuje pozostałe filtry operatorem ... i nadpisuje tylko jeden. filteredJobs powstaje przez jobs.filter(), które sprawdza location i type względem stanu filters, a puste filtry przepuszczają wszystko. Oryginalna tablica jobs się nie zmienia. Wynik liczymy przy każdym renderze, więc nie potrzebujesz do niego osobnego stanu ani useEffect.
Lista typów to zwykły <select>: opcja z pustą wartością oznacza All Types, a pozostałe odpowiadają wartościom pola type. Rzutowanie as any ucisza TypeScripta, ale lepiej użyć dokładniejszego typu, np. Job['type'] | ''.
Dwie pułapki. includes rozróżnia wielkość liter, więc „Tortuga” i „tortuga” to dla niego co innego, dlatego porównuj teksty po toLowerCase(). Filtr wynagrodzenia jest już w stanie, ale lista jeszcze go nie sprawdza. Przycisk Apply Now też na razie nic nie robi, a za chwilę podepniesz pod niego formularz z identyfikatorem oferty.
Formularz aplikacji
Formularz zbiera imię, e-mail, telefon, CV i list motywacyjny, a potem wysyła wszystko jednym żądaniem:
function ApplicationForm({ jobId }: { jobId: number }) {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
resume: null as File | null,
coverLetter: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const data = new FormData();
Object.entries(formData).forEach(([key, value]) => {
if (value) data.append(key, value);
});
await fetch(`/api/jobs/${jobId}/apply`, {
method: 'POST',
body: data
});
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input
onChange={(e) => setFormData({...formData, name: e.target.value})}
required
className="w-full border rounded px-3 py-2"
/>
<input
type="email"
onChange={(e) => setFormData({...formData, email: e.target.value})}
required
className="w-full border rounded px-3 py-2"
/>
<input
type="file"
accept=".pdf,.doc,.docx"
onChange={(e) => setFormData({...formData, resume: e.target.files?.[0] || null})}
required
/>
<textarea
onChange={(e) => setFormData({...formData, coverLetter: e.target.value})}
rows={6}
className="w-full border rounded px-3 py-2"
/>
<button type="submit" className="bg-blue-600 text-white px-6 py-2 rounded">
Submit Application
</button>
</form>
);
}handleSubmit zaczyna od e.preventDefault(), żeby zatrzymać przeładowanie strony, potem buduje FormData z pól formData przez Object.entries, a na koniec wywołuje fetch na /api/jobs/${jobId}/apply metodą POST i wysyła body: data. Adres składa się z trzech części: /api/jobs/, ${jobId} i /apply. Warunek if (value) pomija puste pola i brak pliku. Nie ustawiaj ręcznie nagłówka Content-Type, bo przeglądarka sama doda multipart/form-data razem z granicą oddzielającą pola. Formularz nie czyści się po wysłaniu i nie pokazuje potwierdzenia, to jeszcze przed Tobą.
Atrybut required i type="email" każą przeglądarce sprawdzić pola przed wysłaniem, ale serwer i tak musi zweryfikować dane. Pola nie mają też etykiet. Dodaj <label> do każdego z nich, bo bez etykiet czytnik ekranu nie powie, czego pole oczekuje.
Rada Kapitana Redbearda: nie zapisuj przefiltrowanej listy w osobnym stanie. Wyliczaj ją zawsze z pełnej listy, a na pokładzie zostanie jedna prawda.
Zapamiętaj: filtr to filter() na pełnej liście, a zgłoszenie z plikiem to FormData wysłane przez fetch. W edytorze poniżej masz formularz aplikacji na ofertę z listem motywacyjnym i plikiem CV, a zadania sprawdzą typy ofert, filtrowanie i wysyłkę formularza.
Kod do tej lekcji: App.jsx
// Okno aplikacji - zgłoszenie na stanowisko
import { useState } from 'react';
const job = {
title: 'Pierwszy oficer',
company: 'Załoga Czarnej Perły'
};
export default function App() {
const [showModal, setShowModal] = useState(false);
const [coverLetter, setCoverLetter] = useState('');
const [resumeFile, setResumeFile] = useState(null);
const [applied, setApplied] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
setApplied(true);
setShowModal(false);
};
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow p-8 text-center">
<h1 className="text-3xl font-bold mb-4">{job.title}</h1>
<p className="text-xl text-gray-700 mb-6">{job.company}</p>
<button
onClick={() => setShowModal(true)}
disabled={applied}
className={`px-8 py-3 rounded-full font-bold transition ${
applied
? 'bg-green-100 text-green-700 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{applied ? 'Zgłoszenie wysłane' : 'Aplikuj teraz'}
</button>
</div>
{/* Okno modalne */}
{showModal && (
<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">Aplikuj do: {job.company}</h2>
<p className="text-gray-600">{job.title}</p>
</div>
<button
onClick={() => setShowModal(false)}
className="text-gray-500 hover:text-gray-700 text-3xl"
>
×
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Dane kontaktowe */}
<div>
<h3 className="font-bold mb-3">Dane kontaktowe</h3>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600">
Dane z Twojego profilu zostaną przekazane pracodawcy
</p>
</div>
</div>
{/* CV */}
<div>
<label className="block font-bold mb-2">CV *</label>
<input
type="file"
accept=".pdf,.doc,.docx"
onChange={(e) => setResumeFile(e.target.files?.[0] || null)}
className="w-full p-3 border rounded-lg"
required
/>
<p className="text-xs text-gray-500 mt-1">PDF, DOC lub DOCX (maks. 5 MB)</p>
</div>
{/* List motywacyjny */}
<div>
<label className="block font-bold mb-2">List motywacyjny (opcjonalnie)</label>
<textarea
value={coverLetter}
onChange={(e) => setCoverLetter(e.target.value)}
placeholder="Dlaczego świetnie nadajesz się na to stanowisko?"
className="w-full p-3 border rounded-lg resize-none"
rows={8}
/>
<p className="text-xs text-gray-500 mt-1">Liczba znaków: {coverLetter.length}</p>
</div>
{/* Przyciski */}
<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"
>
Wyślij zgłoszenie
</button>
<button
type="button"
onClick={() => setShowModal(false)}
className="px-6 py-3 border-2 border-gray-300 rounded-full font-bold hover:bg-gray-50"
>
Anuluj
</button>
</div>
</form>
</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. Jakie wartości może mieć pole type w interfejsie Job (job board)?
2. Jak JobBoard pokazuje tylko pasujące oferty?
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż w jednej linii ścieżkę wysyłki aplikacji z ApplicationForm: /api/jobs/{jobId}/apply
- Układanie w pionie
Uporządkuj kroki wysyłki formularza aplikacji (handleSubmit) w ApplicationForm: