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

Tablica ofert i aplikacje

4 min czytania
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. 1. Jakie wartości może mieć pole type w interfejsie Job (job board)?

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

Przydatne artykuły