Kurs Next.js · Moduł 1: Konfiguracja Next.js

Pierwsza aplikacja "Hello World" - krok po kroku - prosta strona dla firmy kosmicznej

15 min czytania
W tej lekcji16

W Metropolii Quantum każdy inżynier pamięta swój pierwszy projekt - moment, gdy po raz pierwszy uruchomił własny system kwantowy. W świecie deweloperów Next.js tym pierwszym krokiem jest stworzenie aplikacji "Hello World". W tej lekcji przeprowadzimy Cię krok po kroku przez proces tworzenia prostej strony internetowej dla fikcyjnej firmy kosmicznej "Quantum Voyages", wykorzystując najnowsze funkcje Next.js 16 i App Router.

Przygotowanie projektu

Najpierw musimy stworzyć nowy projekt Next.js. Otwórz terminal i wykonaj następujące polecenie:

1npx create-next-app@latest quantum-voyages

W trakcie procesu inicjalizacji odpowiedz na pytania konfiguracyjne:

1Would you like to use the recommended Next.js defaults? No, customize settings
2Would you like to use TypeScript? Yes
3Which linter would you like to use? ESLint
4Would you like to use React Compiler? No
5Would you like to use Tailwind CSS? Yes
6Would you like your code inside a `src/` directory? Yes
7Would you like to use App Router? (recommended) Yes
8Would you like to customize the import alias (`@/*` by default)? No (domyślny alias @/* wystarczy)
9Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code? Yes

Gdy proces instalacji zakończy się, przejdź do katalogu projektu:

1cd quantum-voyages

Struktura projektu

Po utworzeniu projektu zobaczysz strukturę katalogów podobną do tej:

1quantum-voyages/
2├── node_modules/
3├── public/
4├── src/
5│   ├── app/
6│   │   ├── favicon.ico
7│   │   ├── globals.css
8│   │   ├── layout.tsx
9│   │   └── page.tsx
10├── AGENTS.md
11├── CLAUDE.md
12├── eslint.config.mjs
13├── next-env.d.ts
14├── next.config.ts
15├── package.json
16├── postcss.config.mjs
17└── tsconfig.json

Tworzenie strony głównej

Zacznijmy od modyfikacji strony głównej. Otwórz plik src/app/page.tsx i zastąp jego zawartość poniższym kodem:

1// src/app/page.tsx
2import Link from 'next/link';
3
4export default function HomePage() {
5  return (
6    <main className="flex min-h-screen flex-col items-center justify-center p-24">
7      <h1 className="text-6xl font-bold text-center bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-8">
8        Quantum Voyages
9      </h1>
10      <p className="text-xl text-center max-w-2xl mb-8">
11        Odkrywamy wszechświat używając najnowszych technologii kwantowych.
12        Dołącz do naszej międzygwiezdnej podróży i razem przekraczajmy granice możliwości.
13      </p>
14      <div className="flex gap-4">
15        <Link
16          href="/oferta"
17          className="px-6 py-3 rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-colors"
18        >
19          Nasza oferta
20        </Link>
21        <Link
22          href="/kontakt"
23          className="px-6 py-3 rounded-full bg-purple-600 text-white hover:bg-purple-700 transition-colors"
24        >
25          Kontakt
26        </Link>
27      </div>
28    </main>
29  );
30}

Globalne style i layout

Następnie zmodyfikujmy globalny layout naszej aplikacji. Otwórz plik src/app/layout.tsx i zaktualizuj go:

1// src/app/layout.tsx
2import './globals.css';
3import type { Metadata } from 'next';
4import { Inter } from 'next/font/google';
5
6const inter = Inter({ subsets: ['latin'] });
7
8export const metadata: Metadata = {
9  title: 'Quantum Voyages - Podróże międzygwiezdne',
10  description: 'Firma kosmiczna specjalizująca się w międzygwiezdnych podróżach kwantowych',
11};
12
13export default function RootLayout({
14  children,
15}: {
16  children: React.ReactNode;
17}) {
18  return (
19    <html lang="pl">
20      <body className={inter.className}>
21        {children}
22      </body>
23    </html>
24  );
25}

Tworzenie komponentu nagłówka (header)

Teraz stwórzmy wspólny nagłówek, który będzie wyświetlany na wszystkich stronach. Najpierw utworzymy katalog dla współdzielonych komponentów:

1mkdir -p src/components/ui

Następnie utwórz plik src/components/ui/Header.tsx:

1// src/components/ui/Header.tsx
2"use client";
3
4import Link from 'next/link';
5import { useState } from 'react';
6
7export default function Header() {
8  const [isMenuOpen, setIsMenuOpen] = useState(false);
9
10  return (
11    <header className="fixed w-full bg-black/80 backdrop-blur-md text-white z-10">
12      <div className="container mx-auto px-4 py-4 flex justify-between items-center">
13        <Link href="/" className="text-2xl font-bold">
14          Quantum Voyages
15        </Link>
16
17        {/* Mobile Menu Button */}
18        <button
19          className="md:hidden"
20          onClick={() => setIsMenuOpen(!isMenuOpen)}
21        >
22          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" className="w-6 h-6">
23            {isMenuOpen
24              ? <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
25              : <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16m-7 6h7" />
26            }
27          </svg>
28        </button>
29
30        {/* Desktop Navigation */}
31        <nav className="hidden md:flex gap-6">
32          <Link href="/" className="hover:text-blue-400 transition-colors">
33            Strona główna
34          </Link>
35          <Link href="/oferta" className="hover:text-blue-400 transition-colors">
36            Nasza oferta
37          </Link>
38          <Link href="/o-nas" className="hover:text-blue-400 transition-colors">
39            O nas
40          </Link>
41          <Link href="/kontakt" className="hover:text-blue-400 transition-colors">
42            Kontakt
43          </Link>
44        </nav>
45      </div>
46
47      {/* Mobile Navigation */}
48      {isMenuOpen && (
49        <nav className="flex flex-col md:hidden bg-black/90 py-4">
50          <Link
51            href="/"
52            className="px-4 py-2 hover:bg-blue-900/30"
53            onClick={() => setIsMenuOpen(false)}
54          >
55            Strona główna
56          </Link>
57          <Link
58            href="/oferta"
59            className="px-4 py-2 hover:bg-blue-900/30"
60            onClick={() => setIsMenuOpen(false)}
61          >
62            Nasza oferta
63          </Link>
64          <Link
65            href="/o-nas"
66            className="px-4 py-2 hover:bg-blue-900/30"
67            onClick={() => setIsMenuOpen(false)}
68          >
69            O nas
70          </Link>
71          <Link
72            href="/kontakt"
73            className="px-4 py-2 hover:bg-blue-900/30"
74            onClick={() => setIsMenuOpen(false)}
75          >
76            Kontakt
77          </Link>
78        </nav>
79      )}
80    </header>
81  );
82}

Zauważ, że użyliśmy dyrektywy "use client" na górze pliku, ponieważ ten komponent używa hooków React i obsługuje zdarzenia użytkownika.

Teraz stwórzmy stopkę, która również będzie obecna na wszystkich stronach. Utwórz plik src/components/ui/Footer.tsx:

1// src/components/ui/Footer.tsx
2import Link from 'next/link';
3
4export default function Footer() {
5  const currentYear = new Date().getFullYear();
6
7  return (
8    <footer className="bg-black/80 backdrop-blur-md text-white py-8">
9      <div className="container mx-auto px-4">
10        <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
11          <div>
12            <h3 className="text-xl font-bold mb-4">Quantum Voyages</h3>
13            <p className="text-gray-300">
14              Odkrywamy wszechświat używając najnowszych technologii kwantowych.
15            </p>
16          </div>
17
18          <div>
19            <h3 className="text-xl font-bold mb-4">Nawigacja</h3>
20            <ul className="space-y-2">
21              <li><Link href="/" className="text-gray-300 hover:text-white transition-colors">Strona główna</Link></li>
22              <li><Link href="/oferta" className="text-gray-300 hover:text-white transition-colors">Nasza oferta</Link></li>
23              <li><Link href="/o-nas" className="text-gray-300 hover:text-white transition-colors">O nas</Link></li>
24              <li><Link href="/kontakt" className="text-gray-300 hover:text-white transition-colors">Kontakt</Link></li>
25            </ul>
26          </div>
27
28          <div>
29            <h3 className="text-xl font-bold mb-4">Kontakt</h3>
30            <address className="not-italic text-gray-300">
31              <p>Stacja Orbitalna Alfa</p>
32              <p>Orbita geostacjonarna, Ziemia</p>
33              <p>kontakt@quantum-voyages.space</p>
34            </address>
35          </div>
36        </div>
37
38        <div className="border-t border-gray-800 mt-8 pt-8 text-center">
39          <p className="text-gray-400">© {currentYear} Quantum Voyages. Wszelkie prawa zastrzeżone.</p>
40        </div>
41      </div>
42    </footer>
43  );
44}

Aktualizacja głównego layoutu

Teraz zaktualizujmy główny layout aplikacji, aby zawierał utworzone przez nas nagłówek i stopkę:

1// src/app/layout.tsx
2import './globals.css';
3import type { Metadata } from 'next';
4import { Inter } from 'next/font/google';
5import Header from '@/components/ui/Header';
6import Footer from '@/components/ui/Footer';
7
8const inter = Inter({ subsets: ['latin'] });
9
10export const metadata: Metadata = {
11  title: 'Quantum Voyages - Podróże międzygwiezdne',
12  description: 'Firma kosmiczna specjalizująca się w międzygwiezdnych podróżach kwantowych',
13};
14
15export default function RootLayout({
16  children,
17}: {
18  children: React.ReactNode;
19}) {
20  return (
21    <html lang="pl">
22      <body className={inter.className + " bg-gradient-to-b from-gray-900 to-black text-white min-h-screen"}>
23        <Header />
24        <div className="pt-20">
25          {children}
26        </div>
27        <Footer />
28      </body>
29    </html>
30  );
31}

Aktualizacja strony głównej

Teraz dostosujmy stronę główną, aby lepiej pasowała do naszego nowego layoutu:

1// src/app/page.tsx
2import Link from 'next/link';
3import Image from 'next/image';
4
5export default function HomePage() {
6  return (
7    <>
8      {/* Hero Section */}
9      <section className="h-[90vh] flex flex-col items-center justify-center text-center p-4 relative overflow-hidden">
10        <div className="absolute inset-0 z-0">
11          <div className="absolute inset-0 bg-black/60 z-10"></div>
12          <Image
13            src="/space.jpg"
14            alt="Przestrzeń kosmiczna"
15            fill
16            className="object-cover"
17            preload
18          />
19        </div>
20
21        <div className="relative z-20 max-w-4xl mx-auto">
22          <h1 className="text-6xl font-bold mb-6 bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
23            Quantum Voyages
24          </h1>
25          <p className="text-xl mb-8 text-gray-200">
26            Przełamujemy granice przestrzeni i czasu dzięki technologii kwantowej.
27            Jesteśmy pionierami w dziedzinie międzygwiezdnej eksploracji.
28          </p>
29          <div className="flex flex-wrap gap-4 justify-center">
30            <Link
31              href="/oferta"
32              className="px-8 py-3 rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-colors"
33            >
34              Odkryj nasze misje
35            </Link>
36            <Link
37              href="/kontakt"
38              className="px-8 py-3 rounded-full bg-purple-600 text-white hover:bg-purple-700 transition-colors"
39            >
40              Dołącz do załogi
41            </Link>
42          </div>
43        </div>
44      </section>
45
46      {/* Features Section */}
47      <section className="py-20 px-4">
48        <div className="container mx-auto">
49          <h2 className="text-4xl font-bold text-center mb-16">Nasze przewagi kwantowe</h2>
50
51          <div className="grid grid-cols-1 md:grid-cols-3 gap-10">
52            <div className="p-6 bg-gradient-to-br from-blue-900/30 to-blue-800/10 rounded-xl backdrop-blur-sm">
53              <div className="w-16 h-16 bg-blue-600 rounded-lg flex items-center justify-center mb-4">
54                <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
55                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
56                </svg>
57              </div>
58              <h3 className="text-xl font-semibold mb-2">Napęd kwantowy</h3>
59              <p className="text-gray-300">
60                Nasze statki kosmiczne wykorzystują najnowszą technologię napędu kwantowego,
61                umożliwiającą podróże z prędkością przekraczającą prędkość światła.
62              </p>
63            </div>
64
65            <div className="p-6 bg-gradient-to-br from-purple-900/30 to-purple-800/10 rounded-xl backdrop-blur-sm">
66              <div className="w-16 h-16 bg-purple-600 rounded-lg flex items-center justify-center mb-4">
67                <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
68                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
69                </svg>
70              </div>
71              <h3 className="text-xl font-semibold mb-2">Osłony kwantowe</h3>
72              <p className="text-gray-300">
73                Zaawansowane osłony oparte na mechanice kwantowej zapewniają
74                najwyższy poziom bezpieczeństwa podczas międzygwiezdnych podróży.
75              </p>
76            </div>
77
78            <div className="p-6 bg-gradient-to-br from-teal-900/30 to-teal-800/10 rounded-xl backdrop-blur-sm">
79              <div className="w-16 h-16 bg-teal-600 rounded-lg flex items-center justify-center mb-4">
80                <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
81                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
82                </svg>
83              </div>
84              <h3 className="text-xl font-semibold mb-2">Laboratoria kwantowe</h3>
85              <p className="text-gray-300">
86                Nasze statki wyposażone są w najnowocześniejsze laboratoria kwantowe,
87                umożliwiające badania naukowe w przestrzeni kosmicznej.
88              </p>
89            </div>
90          </div>
91        </div>
92      </section>
93
94      {/* Call to Action */}
95      <section className="py-16 px-4 bg-gradient-to-r from-blue-900/40 to-purple-900/40">
96        <div className="container mx-auto text-center">
97          <h2 className="text-3xl font-bold mb-4">Gotowy na międzygwiezdną przygodę?</h2>
98          <p className="text-xl text-gray-300 mb-8 max-w-2xl mx-auto">
99            Dołącz do społeczności odkrywców kwantowych i bądź częścią
100            następnego wielkiego kroku ludzkości w eksploracji kosmosu.
101          </p>
102          <Link
103            href="/kontakt"
104            className="px-8 py-3 rounded-full bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:from-blue-700 hover:to-purple-700 transition-colors"
105          >
106            Dowiedz się więcej
107          </Link>
108        </div>
109      </section>
110    </>
111  );
112}

Przygotowanie zasobów

Aby nasza strona wyglądała atrakcyjnie, potrzebujemy obrazu tła dla sekcji hero. Pobierz zdjęcie przestrzeni kosmicznej (lub użyj własnego) i umieść je w katalogu public/ jako space.jpg.

Tworzenie podstrony "Oferta"

Teraz stwórzmy podstronę z ofertą firmy. Utwórz katalog src/app/oferta i plik page.tsx wewnątrz:

1// src/app/oferta/page.tsx
2import Image from 'next/image';
3
4interface MissionProps {
5  title: string;
6  description: string;
7  image: string;
8  destination: string;
9  duration: string;
10  price: string;
11}
12
13function Mission({ title, description, image, destination, duration, price }: MissionProps) {
14  return (
15    <div className="bg-gradient-to-br from-gray-800/50 to-gray-900/50 rounded-xl overflow-hidden backdrop-blur-sm">
16      <div className="relative h-64">
17        <Image
18          src={image}
19          alt={title}
20          fill
21          className="object-cover"
22        />
23      </div>
24      <div className="p-6">
25        <h3 className="text-2xl font-bold mb-2">{title}</h3>
26        <p className="text-gray-300 mb-4">{description}</p>
27        <div className="grid grid-cols-2 gap-4 text-sm">
28          <div>
29            <p className="text-gray-400">Cel podróży</p>
30            <p className="font-medium">{destination}</p>
31          </div>
32          <div>
33            <p className="text-gray-400">Czas trwania</p>
34            <p className="font-medium">{duration}</p>
35          </div>
36          <div className="col-span-2">
37            <p className="text-gray-400">Koszt</p>
38            <p className="font-bold text-xl text-blue-400">{price}</p>
39          </div>
40        </div>
41      </div>
42    </div>
43  );
44}
45
46export default function OfertaPage() {
47  const missions = [
48    {
49      title: "Wycieczka Orbitalna",
50      description: "Idealna dla początkujących odkrywców kosmosu. Doświadcz nieważkości i podziwiaj Ziemię z orbity.",
51      image: "/mission-orbit.jpg",
52      destination: "Niska orbita Ziemi",
53      duration: "3 dni",
54      price: "250,000 ¤"
55    },
56    {
57      title: "Weekend na Księżycu",
58      description: "Spędź niezapomniany weekend w luksusowym kompleksie na powierzchni Księżyca.",
59      image: "/mission-moon.jpg",
60      destination: "Księżyc",
61      duration: "5 dni",
62      price: "750,000 ¤"
63    },
64    {
65      title: "Ekspedycja na Marsa",
66      description: "Odkryj czerwoną planetę i bądź jednym z pierwszych ludzi, którzy postawili na niej stopę.",
67      image: "/mission-mars.jpg",
68      destination: "Mars",
69      duration: "3 miesiące",
70      price: "5,000,000 ¤"
71    },
72    {
73      title: "Podróż do Proximy Centauri",
74      description: "Międzygwiezdna ekspedycja do najbliższej gwiazdy poza naszym Układem Słonecznym.",
75      image: "/mission-proxima.jpg",
76      destination: "Proxima Centauri",
77      duration: "5 lat (czas na pokładzie: 6 miesięcy)",
78      price: "25,000,000 ¤"
79    }
80  ];
81
82  return (
83    <div className="container mx-auto px-4 py-16">
84      <div className="text-center mb-16">
85        <h1 className="text-5xl font-bold mb-4">Nasze misje kosmiczne</h1>
86        <p className="text-xl text-gray-300 max-w-3xl mx-auto">
87          Wybierz swoją podróż kosmiczną spośród naszych ofert. Od krótkiej wycieczki orbitalnej
88          po międzygwiezdne ekspedycje - każda z nich zmieni na zawsze Twoje postrzeganie wszechświata.
89        </p>
90      </div>
91
92      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
93        {missions.map((mission, index) => (
94          <Mission key={index} {...mission} />
95        ))}
96      </div>
97
98      <div className="mt-16 p-8 bg-gradient-to-r from-blue-900/30 to-purple-900/30 rounded-xl backdrop-blur-sm">
99        <h2 className="text-3xl font-bold mb-4">Misje niestandardowe</h2>
100        <p className="text-gray-300 mb-6">
101          Poszukujesz czegoś wyjątkowego? Oferujemy ekskluzywne, niestandardowe misje
102          dostosowane do Twoich konkretnych oczekiwań i potrzeb badawczych.
103        </p>
104        <button className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 rounded-full hover:from-blue-700 hover:to-purple-700 transition-colors">
105          Skontaktuj się z nami
106        </button>
107      </div>
108    </div>
109  );
110}

Dla tej podstrony potrzebne będą także obrazy. Możesz dodać własne pliki do katalogu public/ lub użyć istniejących obrazów.

Tworzenie podstrony "Kontakt"

Teraz stwórzmy podstronę kontaktową z formularzem. Utwórz katalog src/app/kontakt i plik page.tsx wewnątrz:

1// src/app/kontakt/page.tsx
2import ContactForm from './ContactForm';
3
4export default function KontaktPage() {
5  return (
6    <div className="container mx-auto px-4 py-16">
7      <div className="text-center mb-16">
8        <h1 className="text-5xl font-bold mb-4">Skontaktuj się z nami</h1>
9        <p className="text-xl text-gray-300 max-w-3xl mx-auto">
10          Masz pytania dotyczące naszych misji kosmicznych lub chcesz dołączyć do załogi?
11          Skorzystaj z poniższego formularza lub odwiedź nasze biuro.
12        </p>
13      </div>
14
15      <div className="grid grid-cols-1 lg:grid-cols-2 gap-16">
16        <div>
17          <h2 className="text-3xl font-semibold mb-6">Wyślij wiadomość</h2>
18          <ContactForm />
19        </div>
20
21        <div>
22          <h2 className="text-3xl font-semibold mb-6">Dane kontaktowe</h2>
23
24          <div className="space-y-6">
25            <div className="flex items-start">
26              <div className="bg-blue-600 p-3 rounded-lg mr-4">
27                <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
28                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
29                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
30                </svg>
31              </div>
32              <div>
33                <h3 className="text-xl font-medium mb-1">Adres</h3>
34                <address className="not-italic text-gray-300">
35                  <p>Stacja Orbitalna Alfa</p>
36                  <p>Orbita geostacjonarna, Ziemia</p>
37                </address>
38              </div>
39            </div>
40
41            <div className="flex items-start">
42              <div className="bg-blue-600 p-3 rounded-lg mr-4">
43                <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
44                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
45                </svg>
46              </div>
47              <div>
48                <h3 className="text-xl font-medium mb-1">Email</h3>
49                <p className="text-gray-300">kontakt@quantum-voyages.space</p>
50              </div>
51            </div>
52
53            <div className="flex items-start">
54              <div className="bg-blue-600 p-3 rounded-lg mr-4">
55                <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
56                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
57                </svg>
58              </div>
59              <div>
60                <h3 className="text-xl font-medium mb-1">Telefon</h3>
61                <p className="text-gray-300">+0 (123) 456-7890</p>
62              </div>
63            </div>
64          </div>
65
66          <div className="mt-10">
67            <h2 className="text-3xl font-semibold mb-6">Godziny pracy</h2>
68            <div className="bg-gradient-to-br from-gray-800/50 to-gray-900/50 rounded-xl p-6 backdrop-blur-sm">
69              <div className="flex justify-between py-2 border-b border-gray-700">
70                <span>Poniedziałek - Piątek:</span>
71                <span>09:00 - 18:00</span>
72              </div>
73              <div className="flex justify-between py-2 border-b border-gray-700">
74                <span>Sobota:</span>
75                <span>10:00 - 15:00</span>
76              </div>
77              <div className="flex justify-between py-2">
78                <span>Niedziela:</span>
79                <span>Zamknięte</span>
80              </div>
81            </div>
82          </div>
83        </div>
84      </div>
85    </div>
86  );
87}

Teraz utwórzmy komponent formularza kontaktowego w pliku src/app/kontakt/ContactForm.tsx:

1// src/app/kontakt/ContactForm.tsx
2"use client";
3
4import { useState } from 'react';
5
6export default function ContactForm() {
7  const [formData, setFormData] = useState({
8    name: '',
9    email: '',
10    subject: '',
11    message: ''
12  });
13
14  const [formStatus, setFormStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
15
16  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
17    const { name, value } = e.target;
18    setFormData(prevData => ({
19      ...prevData,
20      [name]: value
21    }));
22  };
23
24  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
25    e.preventDefault();
26    setFormStatus('loading');
27
28    // W rzeczywistej aplikacji tutaj byłaby obsługa formularza, np. wysłanie danych do API
29    try {
30      // Symulacja opóźnienia odpowiedzi serwera
31      await new Promise(resolve => setTimeout(resolve, 1500));
32
33      // Symulacja sukcesu
34      setFormStatus('success');
35      setFormData({
36        name: '',
37        email: '',
38        subject: '',
39        message: ''
40      });
41    } catch (error) {
42      setFormStatus('error');
43    }
44  };
45
46  return (
47    <form onSubmit={handleSubmit} className="space-y-6">
48      <div>
49        <label htmlFor="name" className="block text-sm font-medium mb-1">
50          Imię i nazwisko <span className="text-red-500">*</span>
51        </label>
52        <input
53          type="text"
54          id="name"
55          name="name"
56          value={formData.name}
57          onChange={handleChange}
58          required
59          className="w-full px-4 py-2 bg-gray-800/60 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
60        />
61      </div>
62
63      <div>
64        <label htmlFor="email" className="block text-sm font-medium mb-1">
65          Email <span className="text-red-500">*</span>
66        </label>
67        <input
68          type="email"
69          id="email"
70          name="email"
71          value={formData.email}
72          onChange={handleChange}
73          required
74          className="w-full px-4 py-2 bg-gray-800/60 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
75        />
76      </div>
77
78      <div>
79        <label htmlFor="subject" className="block text-sm font-medium mb-1">
80          Temat <span className="text-red-500">*</span>
81        </label>
82        <select
83          id="subject"
84          name="subject"
85          value={formData.subject}
86          onChange={handleChange}
87          required
88          className="w-full px-4 py-2 bg-gray-800/60 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
89        >
90          <option value="">Wybierz temat</option>
91          <option value="rezerwacja">Rezerwacja misji</option>
92          <option value="informacje">Informacje o misjach</option>
93          <option value="praca">Dołączenie do załogi</option>
94          <option value="inne">Inne</option>
95        </select>
96      </div>
97
98      <div>
99        <label htmlFor="message" className="block text-sm font-medium mb-1">
100          Wiadomość <span className="text-red-500">*</span>
101        </label>
102        <textarea
103          id="message"
104          name="message"
105          value={formData.message}
106          onChange={handleChange}
107          required
108          rows={5}
109          className="w-full px-4 py-2 bg-gray-800/60 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
110        ></textarea>
111      </div>
112
113      <button
114        type="submit"
115        disabled={formStatus === 'loading'}
116        className="w-full px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg hover:from-blue-700 hover:to-purple-700 transition-colors disabled:opacity-70"
117      >
118        {formStatus === 'loading' ? 'Wysyłanie...' : 'Wyślij wiadomość'}
119      </button>
120
121      {formStatus === 'success' && (
122        <div className="p-4 bg-green-900/40 border border-green-500 rounded-lg text-green-200">
123          Dziękujemy za wiadomość! Odpowiemy tak szybko, jak to możliwe.
124        </div>
125      )}
126
127      {formStatus === 'error' && (
128        <div className="p-4 bg-red-900/40 border border-red-500 rounded-lg text-red-200">
129          Wystąpił błąd podczas wysyłania wiadomości. Proszę spróbować ponownie później.
130        </div>
131      )}
132    </form>
133  );
134}

Tworzenie strony 404 (Not Found)

Dodajmy stronę 404, która będzie wyświetlana, gdy użytkownik wejdzie na nieistniejącą ścieżkę. Utwórz plik src/app/not-found.tsx:

1// src/app/not-found.tsx
2import Link from 'next/link';
3
4export default function NotFound() {
5  return (
6    <div className="min-h-[80vh] flex flex-col items-center justify-center p-4 text-center">
7      <h1 className="text-9xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
8        404
9      </h1>
10      <h2 className="text-4xl font-bold mt-4 mb-6">Zagubiony w kosmosie</h2>
11      <p className="text-xl text-gray-300 mb-8 max-w-xl">
12        Wygląda na to, że zabłądziłeś w głębokim kosmosie. Niestety, strona której
13        szukasz znajduje się poza naszym zasięgiem kwantowym.
14      </p>
15      <Link
16        href="/"
17        className="px-8 py-3 rounded-full bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:from-blue-700 hover:to-purple-700 transition-colors"
18      >
19        Wróć na Ziemię
20      </Link>
21    </div>
22  );
23}

Uruchomienie aplikacji

Teraz możemy uruchomić naszą aplikację lokalnie za pomocą polecenia:

1npm run dev

Po uruchomieniu serwera deweloperskiego otwórz w przeglądarce adres http://localhost:3000, aby zobaczyć swoją stronę "Hello World" dla firmy kosmicznej.

Budowanie i wdrażanie

Gdy jesteś zadowolony z działania aplikacji lokalnie, możesz ją zbudować i przygotować do wdrożenia:

1npm run build

Polecenie to wygeneruje zoptymalizowaną wersję produkcyjną aplikacji w katalogu .next/. Możesz także przetestować tę wersję lokalnie, uruchamiając:

1npm run start

Wdrażanie na Vercel

Next.js jest tworzony i utrzymywany przez Vercel, więc wdrożenie na tej platformie jest niezwykle proste:

  1. Utwórz konto na Vercel (jeśli jeszcze go nie masz)
  2. Połącz swoje repozytorium z Vercelem (GitHub, GitLab lub Bitbucket)
  3. Wybierz swój projekt i kliknij "Import"
  4. Vercel automatycznie wykryje, że jest to aplikacja Next.js i skonfiguruje wdrożenie
  5. Kliknij "Deploy" i poczekaj na zakończenie procesu

Po zakończeniu wdrożenia Vercel dostarczy Ci URL, pod którym Twoja aplikacja jest dostępna publicznie.

Co dalej?

Stworzyliśmy prostą, ale elegancką i funkcjonalną stronę dla firmy kosmicznej z wykorzystaniem Next.js 16 i App Routera. Oto kilka sugestii do dalszego rozwijania aplikacji:

  1. Dodaj podstronę "O nas" z historią i misją firmy
  2. Zaimplementuj animacje za pomocą bibliotek takich jak Framer Motion
  3. Dodaj prawdziwą obsługę formularza z wykorzystaniem Server Actions
  4. Zintegruj CMS (np. Contentful, Sanity) do zarządzania treścią
  5. Dodaj internacjonalizację (i18n) do obsługi wielu języków
  6. Zaimplementuj system uwierzytelniania dla klientów i członków załogi

W miarę rozbudowy aplikacji pamiętaj o zasadach i najlepszych praktykach poznanych w poprzednich lekcjach, szczególnie o właściwym podziale na Server i Client Components.

Gratulacje! Właśnie stworzyłeś swoją pierwszą aplikację Next.js 16 z wykorzystaniem App Routera.

Kod do tej lekcji: App.tsx
1// Zarządzanie stanem - Metropolis Quantum
2import React, { useState, createContext, useContext } from 'react';
3
4console.log("Zarządzanie stanem w React");
5console.log("Context API i podnoszenie stanu\n");
6
7// ==========================================
8// 1. Podnoszenie stanu (lifting state up)
9// ==========================================
10
11function TemperatureInput({ scale, temperature, onTemperatureChange }: any) {
12  return (
13    <div style={{ marginBottom: '15px' }}>
14      <label style={{ display: 'block', marginBottom: '5px', color: '#64ffda' }}>
15        Temperatura w skali {scale === 'celsius' ? 'Celsjusza' : 'Fahrenheita'}:
16      </label>
17      <input
18        type="number"
19        value={temperature}
20        onChange={(e) => onTemperatureChange(e.target.value)}
21        style={{
22          width: '100%',
23          padding: '10px',
24          borderRadius: '6px',
25          border: '1px solid #64ffda',
26          background: 'rgba(255, 255, 255, 0.1)',
27          color: 'white',
28          fontSize: '1rem'
29        }}
30      />
31    </div>
32  );
33}
34
35function TemperatureCalculator() {
36  const [temperature, setTemperature] = useState('');
37  const [scale, setScale] = useState('celsius');
38
39  const handleCelsiusChange = (temp: string) => {
40    setTemperature(temp);
41    setScale('celsius');
42  };
43
44  const handleFahrenheitChange = (temp: string) => {
45    setTemperature(temp);
46    setScale('fahrenheit');
47  };
48
49  const toCelsius = (fahrenheit: number) => (fahrenheit - 32) * 5 / 9;
50  const toFahrenheit = (celsius: number) => (celsius * 9 / 5) + 32;
51
52  const celsius = scale === 'fahrenheit' ? toCelsius(parseFloat(temperature)) : parseFloat(temperature);
53  const fahrenheit = scale === 'celsius' ? toFahrenheit(parseFloat(temperature)) : parseFloat(temperature);
54
55  return (
56    <div style={{
57      background: 'rgba(255, 255, 255, 0.1)',
58      padding: '20px',
59      borderRadius: '12px',
60      border: '2px solid #7c4dff'
61    }}>
62      <h3 style={{ color: '#7c4dff' }}>Przelicznik temperatury (podnoszenie stanu)</h3>
63
64      <TemperatureInput
65        scale="celsius"
66        temperature={scale === 'celsius' ? temperature : isNaN(celsius) ? '' : celsius.toFixed(1)}
67        onTemperatureChange={handleCelsiusChange}
68      />
69
70      <TemperatureInput
71        scale="fahrenheit"
72        temperature={scale === 'fahrenheit' ? temperature : isNaN(fahrenheit) ? '' : fahrenheit.toFixed(1)}
73        onTemperatureChange={handleFahrenheitChange}
74      />
75
76      <div style={{
77        marginTop: '15px',
78        padding: '15px',
79        background: celsius >= 100 ? 'rgba(244, 67, 54, 0.3)' : 'rgba(33, 150, 243, 0.3)',
80        borderRadius: '8px',
81        textAlign: 'center'
82      }}>
83        {celsius >= 100 ? 'Woda gotuje się!' : 'Woda nie gotuje się'}
84      </div>
85    </div>
86  );
87}
88
89// ==========================================
90// 2. Context API
91// ==========================================
92
93interface ThemeContextType {
94  theme: 'dark' | 'light';
95  toggleTheme: () => void;
96}
97
98const ThemeContext = createContext<ThemeContextType>({
99  theme: 'dark',
100  toggleTheme: () => {}
101});
102
103function ThemeButton() {
104  const { theme, toggleTheme } = useContext(ThemeContext);
105
106  return (
107    <button
108      onClick={toggleTheme}
109      style={{
110        background: theme === 'dark'
111          ? 'linear-gradient(45deg, #7c4dff, #e91e63)'
112          : 'linear-gradient(45deg, #ff9800, #ffeb3b)',
113        color: 'white',
114        border: 'none',
115        padding: '12px 24px',
116        borderRadius: '8px',
117        cursor: 'pointer',
118        fontWeight: 'bold',
119        fontSize: '1rem'
120      }}
121    >
122      {theme === 'dark' ? 'Tryb ciemny' : 'Tryb jasny'}
123    </button>
124  );
125}
126
127function ThemedPanel() {
128  const { theme } = useContext(ThemeContext);
129
130  return (
131    <div style={{
132      background: theme === 'dark'
133        ? 'rgba(255, 255, 255, 0.1)'
134        : 'rgba(255, 152, 0, 0.2)',
135      padding: '20px',
136      borderRadius: '12px',
137      border: `2px solid ${theme === 'dark' ? '#64ffda' : '#ff9800'}`,
138      marginTop: '20px'
139    }}>
140      <h3 style={{ color: theme === 'dark' ? '#64ffda' : '#ff9800' }}>
141        Panel z motywem
142      </h3>
143      <p style={{ color: theme === 'dark' ? '#b0bec5' : '#5d4037' }}>
144        Ten panel automatycznie dostosowuje się do aktualnego motywu dzięki Context API!
145      </p>
146      <p style={{ color: theme === 'dark' ? '#b0bec5' : '#5d4037' }}>
147        Aktualny motyw: <strong>{theme === 'dark' ? 'Ciemny' : 'Jasny'}</strong>
148      </p>
149    </div>
150  );
151}
152
153// ==========================================
154// 3. Przykład kontekstu użytkownika
155// ==========================================
156
157interface User {
158  name: string;
159  role: string;
160  level: number;
161}
162
163interface UserContextType {
164  user: User | null;
165  login: (user: User) => void;
166  logout: () => void;
167}
168
169const UserContext = createContext<UserContextType>({
170  user: null,
171  login: () => {},
172  logout: () => {}
173});
174
175function UserProfile() {
176  const { user, logout } = useContext(UserContext);
177
178  if (!user) return null;
179
180  return (
181    <div style={{
182      background: 'rgba(76, 175, 80, 0.2)',
183      padding: '15px',
184      borderRadius: '10px',
185      border: '2px solid #4caf50',
186      display: 'flex',
187      justifyContent: 'space-between',
188      alignItems: 'center'
189    }}>
190      <div>
191        <h4 style={{ color: '#4caf50', margin: '0 0 5px 0' }}>{user.name}</h4>
192        <p style={{ margin: '5px 0', color: '#b0bec5' }}>Rola: {user.role}</p>
193        <p style={{ margin: '5px 0', color: '#ff9800' }}>Poziom: {user.level}</p>
194      </div>
195      <button
196        onClick={logout}
197        style={{
198          background: 'linear-gradient(45deg, #f44336, #e91e63)',
199          color: 'white',
200          border: 'none',
201          padding: '8px 16px',
202          borderRadius: '6px',
203          cursor: 'pointer',
204          fontWeight: 'bold'
205        }}
206      >
207        Wyloguj
208      </button>
209    </div>
210  );
211}
212
213function LoginForm() {
214  const { login } = useContext(UserContext);
215  const [name, setName] = useState('');
216
217  const handleLogin = () => {
218    if (name.trim()) {
219      login({
220        name: name,
221        role: 'Inżynier kwantowy',
222        level: Math.floor(Math.random() * 50) + 1
223      });
224    }
225  };
226
227  return (
228    <div style={{
229      background: 'rgba(33, 150, 243, 0.2)',
230      padding: '20px',
231      borderRadius: '12px',
232      border: '2px solid #2196f3'
233    }}>
234      <h3 style={{ color: '#2196f3' }}>Logowanie do systemu kwantowego</h3>
235      <input
236        type="text"
237        placeholder="Wpisz swoje imię..."
238        value={name}
239        onChange={(e) => setName(e.target.value)}
240        style={{
241          width: '100%',
242          padding: '12px',
243          marginTop: '15px',
244          borderRadius: '6px',
245          border: '1px solid #2196f3',
246          background: 'rgba(255, 255, 255, 0.1)',
247          color: 'white',
248          fontSize: '1rem'
249        }}
250      />
251      <button
252        onClick={handleLogin}
253        style={{
254          marginTop: '15px',
255          width: '100%',
256          background: 'linear-gradient(45deg, #2196f3, #21cbf3)',
257          color: 'white',
258          border: 'none',
259          padding: '12px',
260          borderRadius: '6px',
261          cursor: 'pointer',
262          fontWeight: 'bold',
263          fontSize: '1rem'
264        }}
265      >
266        Zaloguj
267      </button>
268    </div>
269  );
270}
271
272// ==========================================
273// Główna aplikacja z providerami
274// ==========================================
275function App() {
276  const [theme, setTheme] = useState<'dark' | 'light'>('dark');
277  const [user, setUser] = useState<User | null>(null);
278
279  const toggleTheme = () => {
280    setTheme(prev => prev === 'dark' ? 'light' : 'dark');
281    console.log(`Zmieniono motyw na: ${theme === 'dark' ? 'jasny' : 'ciemny'}`);
282  };
283
284  const login = (newUser: User) => {
285    setUser(newUser);
286    console.log(`Zalogowano użytkownika: ${newUser.name}`);
287  };
288
289  const logout = () => {
290    setUser(null);
291    console.log("Wylogowano użytkownika");
292  };
293
294  return (
295    <ThemeContext.Provider value={{ theme, toggleTheme }}>
296      <UserContext.Provider value={{ user, login, logout }}>
297        <div style={{
298          padding: '20px',
299          background: theme === 'dark'
300            ? 'linear-gradient(135deg, #0f0f23 0%, #1a1a2e 100%)'
301            : 'linear-gradient(135deg, #fafafa 0%, #e0e0e0 100%)',
302          minHeight: '100vh',
303          color: theme === 'dark' ? 'white' : '#333',
304          transition: 'all 0.3s ease'
305        }}>
306          <h1 style={{
307            textAlign: 'center',
308            color: theme === 'dark' ? '#64ffda' : '#ff9800'
309          }}>
310            Demo zarządzania stanem
311          </h1>
312
313          <div style={{ display: 'flex', justifyContent: 'center', marginTop: '20px' }}>
314            <ThemeButton />
315          </div>
316
317          <div style={{ marginTop: '30px' }}>
318            <TemperatureCalculator />
319          </div>
320
321          <ThemedPanel />
322
323          <div style={{ marginTop: '30px' }}>
324            {user ? <UserProfile /> : <LoginForm />}
325          </div>
326
327          <div style={{
328            marginTop: '30px',
329            background: theme === 'dark'
330              ? 'rgba(100, 255, 218, 0.1)'
331              : 'rgba(255, 152, 0, 0.2)',
332            padding: '20px',
333            borderRadius: '10px',
334            borderLeft: `4px solid ${theme === 'dark' ? '#64ffda' : '#ff9800'}`
335          }}>
336            <h3>Wzorce zarządzania stanem:</h3>
337            <ul style={{ lineHeight: '1.8' }}>
338              <li><strong>Podnoszenie stanu</strong>: Przenoś stan do wspólnego rodzica</li>
339              <li><strong>Context API</strong>: Globalny stan bez prop drillingu</li>
340              <li><strong>Własne hooki</strong>: Logika stanu wielokrotnego użytku</li>
341              <li><strong>Redux/Zustand</strong>: Dla większych aplikacji</li>
342            </ul>
343          </div>
344        </div>
345      </UserContext.Provider>
346    </ThemeContext.Provider>
347  );
348}
349
350export default App;

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. Które z poniższych jest poprawną metodą renderowania listy w React?

  2. 2. Atrybut 'key' w React jest potrzebny przy:

  3. 3. Co robi polecenie npm run build (next build) w projekcie Next.js?

Zadania praktyczne w grze

  • Edytor kodu

    Utwórz komponent LoginStatus, który przyjmuje prop isLoggedIn (true albo false) i wyświetla „Zalogowany”, gdy isLoggedIn jest true, albo „Niezalogowany”, gdy jest false. Użyj renderowania warunkowego (operator ? : albo if).

  • Edytor kodu

    W komponencie App utwórz przycisk <button>, który po kliknięciu (onClick) wyświetla alert z komunikatem 'Przycisk został kliknięty!'.

  • Klikanie w kolejności

    Ułóż składnię deklaracji Client Component w Next.js

  • Edytor kodu

    Stwórz komponent DroneControlPanel (export default) z nagłówkiem <h1> „Panel drona” i elementem <nav> z trzema linkami <Link> z next/link: „Mapa” (/map), „Status” (/status) i „Ustawienia” (/settings).

  • Układanie w pionie

    Ułóż ścieżkę do pliku Route Handler w Next.js, a na końcu funkcję, którą ten plik eksportuje

Przydatne artykuły