לדלג לתוכן

9.2 ראוטר, דפים ולייאאוטים פתרון

פתרון - ראוטר, דפים ולייאאוטים - Router, Pages, and Layouts

פתרון תרגיל 1 - מבנה אתר בסיסי

מבנה התיקיות:

app/
  page.tsx
  about/
    page.tsx
  contact/
    page.tsx
  products/
    page.tsx
    shoes/
      page.tsx
    shirts/
      page.tsx
// app/page.tsx
export default function HomePage() {
  return (
    <main className="p-8">
      <h1 className="text-4xl font-bold">ברוכים הבאים לחנות</h1>
      <p className="mt-4">גלו את המוצרים המובחרים שלנו</p>
    </main>
  );
}
// app/products/page.tsx
export default function ProductsPage() {
  return (
    <div>
      <h1 className="text-3xl font-bold">כל המוצרים</h1>
      <p className="mt-2">בחרו קטגוריה מהתפריט</p>
    </div>
  );
}
// app/products/shoes/page.tsx
export default function ShoesPage() {
  return (
    <div>
      <h1 className="text-3xl font-bold">נעליים</h1>
      <p className="mt-2">מגוון נעליים איכותיות</p>
    </div>
  );
}
// app/products/shirts/page.tsx
export default function ShirtsPage() {
  return (
    <div>
      <h1 className="text-3xl font-bold">חולצות</h1>
      <p className="mt-2">חולצות בכל הסגנונות</p>
    </div>
  );
}
// app/about/page.tsx
export default function AboutPage() {
  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold">אודות</h1>
      <p className="mt-2">חנות מקוונת מובילה מאז 2024</p>
    </main>
  );
}
// app/contact/page.tsx
export default function ContactPage() {
  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold">יצירת קשר</h1>
      <p className="mt-2">שלחו לנו הודעה: info@shop.com</p>
    </main>
  );
}

פתרון תרגיל 2 - לייאאוטים מקוננים

// app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";

export const metadata: Metadata = {
  title: "החנות שלנו",
  description: "חנות מקוונת עם Next.js",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="he" dir="rtl">
      <body className="min-h-screen flex flex-col">
        <nav className="bg-gray-800 text-white p-4">
          <div className="max-w-6xl mx-auto flex gap-6">
            <Link href="/" className="hover:text-gray-300">בית</Link>
            <Link href="/products" className="hover:text-gray-300">מוצרים</Link>
            <Link href="/about" className="hover:text-gray-300">אודות</Link>
            <Link href="/contact" className="hover:text-gray-300">צור קשר</Link>
          </div>
        </nav>

        <div className="flex-1">{children}</div>

        <footer className="bg-gray-200 p-4 text-center">
          <p>כל הזכויות שמורות 2024</p>
        </footer>
      </body>
    </html>
  );
}
// app/products/layout.tsx
import Link from "next/link";

export default function ProductsLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <aside className="w-56 bg-gray-50 p-4 min-h-[calc(100vh-120px)] border-l">
        <h2 className="font-bold text-lg mb-4">קטגוריות</h2>
        <nav className="space-y-2">
          <Link
            href="/products"
            className="block p-2 rounded hover:bg-gray-200"
          >
            כל המוצרים
          </Link>
          <Link
            href="/products/shoes"
            className="block p-2 rounded hover:bg-gray-200"
          >
            נעליים
          </Link>
          <Link
            href="/products/shirts"
            className="block p-2 rounded hover:bg-gray-200"
          >
            חולצות
          </Link>
        </nav>
      </aside>
      <main className="flex-1 p-6">{children}</main>
    </div>
  );
}
  • הלייאאוט הראשי מציג navbar ו-footer בכל הדפים
  • לייאאוט המוצרים מוסיף sidebar רק בדפי המוצרים
  • כשעוברים בין קטגוריות, רק התוכן הראשי (children) משתנה

פתרון תרגיל 3 - קבצים מיוחדים

// app/products/loading.tsx
export default function ProductsLoading() {
  return (
    <div className="p-8">
      <div className="animate-pulse space-y-4">
        <div className="h-8 bg-gray-200 rounded w-1/3" />
        <div className="h-4 bg-gray-200 rounded w-2/3" />
        <div className="grid grid-cols-3 gap-4 mt-6">
          {[1, 2, 3, 4, 5, 6].map((i) => (
            <div key={i} className="h-48 bg-gray-200 rounded" />
          ))}
        </div>
      </div>
    </div>
  );
}
// app/products/error.tsx
"use client";

export default function ProductsError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="p-8 text-center">
      <div className="text-6xl mb-4">!</div>
      <h2 className="text-2xl font-bold text-red-600 mb-2">
        שגיאה בטעינת המוצרים
      </h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={reset}
        className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        נסה שוב
      </button>
    </div>
  );
}
// app/not-found.tsx
import Link from "next/link";

export default function NotFound() {
  return (
    <div className="min-h-[60vh] flex flex-col items-center justify-center text-center p-8">
      <h1 className="text-8xl font-bold text-gray-300">404</h1>
      <h2 className="text-2xl font-bold mt-4">הדף לא נמצא</h2>
      <p className="text-gray-600 mt-2">
        הדף שחיפשת לא קיים או שהועבר למקום אחר
      </p>
      <Link
        href="/"
        className="mt-6 px-6 py-3 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        חזרה לדף הבית
      </Link>
    </div>
  );
}

בדיקת loading עם השהייה:

// app/products/page.tsx
export default async function ProductsPage() {
  // השהייה מלאכותית לבדיקת loading
  await new Promise((resolve) => setTimeout(resolve, 2000));

  return (
    <div>
      <h1 className="text-3xl font-bold">כל המוצרים</h1>
      <p className="mt-2">המוצרים נטענו בהצלחה</p>
    </div>
  );
}

פתרון תרגיל 4 - קבוצות נתיבים

מבנה מעודכן:

app/
  layout.tsx
  page.tsx
  (shop)/
    layout.tsx
    products/
      page.tsx
      shoes/
        page.tsx
      shirts/
        page.tsx
  (info)/
    layout.tsx
    about/
      page.tsx
    contact/
      page.tsx
// app/(shop)/layout.tsx
import Link from "next/link";

export default function ShopLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <aside className="w-56 bg-blue-50 p-4 min-h-[calc(100vh-120px)]">
        <h2 className="font-bold text-lg mb-4 text-blue-800">חנות</h2>
        <nav className="space-y-2">
          <Link href="/products" className="block p-2 rounded hover:bg-blue-100">
            כל המוצרים
          </Link>
          <Link href="/products/shoes" className="block p-2 rounded hover:bg-blue-100">
            נעליים
          </Link>
          <Link href="/products/shirts" className="block p-2 rounded hover:bg-blue-100">
            חולצות
          </Link>
        </nav>
      </aside>
      <main className="flex-1 p-6">{children}</main>
    </div>
  );
}
// app/(info)/layout.tsx
export default function InfoLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="max-w-3xl mx-auto p-8">
      <div className="bg-green-50 p-6 rounded-lg">
        {children}
      </div>
    </div>
  );
}
  • הנתיבים נשארים /products, /about וכו' - שם הקבוצה לא מופיע
  • כל קבוצה מקבלת לייאאוט ועיצוב משלה

פתרון תרגיל 5 - ניווט ולינק פעיל

// app/components/Navbar.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

const links = [
  { href: "/", label: "בית" },
  { href: "/products", label: "מוצרים" },
  { href: "/about", label: "אודות" },
  { href: "/contact", label: "צור קשר" },
];

export default function Navbar() {
  const pathname = usePathname();

  const isActive = (href: string) => {
    if (href === "/") {
      return pathname === "/";
    }
    return pathname.startsWith(href);
  };

  return (
    <nav className="bg-gray-800 text-white p-4">
      <div className="max-w-6xl mx-auto flex gap-6">
        {links.map((link) => (
          <Link
            key={link.href}
            href={link.href}
            className={
              isActive(link.href)
                ? "text-yellow-400 font-bold border-b-2 border-yellow-400 pb-1"
                : "hover:text-gray-300 pb-1"
            }
          >
            {link.label}
          </Link>
        ))}
      </div>
    </nav>
  );
}
  • הפונקציה isActive בודקת אם הנתיב מתחיל עם ה-href של הלינק
  • לנתיב / בודקים שוויון מדויק כדי שלא יהיה תמיד פעיל
  • כשנמצאים ב-/products/shoes, גם הלינק של "מוצרים" מסומן כפעיל

פתרון תרגיל 6 - ניווט פרוגרמטי וחיפוש

// app/search/page.tsx
"use client";

import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useState } from "react";

export default function SearchPage() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const query = searchParams.get("q") || "";
  const [input, setInput] = useState(query);

  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault();
    if (input.trim()) {
      const params = new URLSearchParams(searchParams.toString());
      params.set("q", input.trim());
      router.push(`${pathname}?${params.toString()}`);
    }
  };

  const handleClear = () => {
    setInput("");
    router.push(pathname);
  };

  return (
    <main className="p-8 max-w-2xl mx-auto">
      <h1 className="text-3xl font-bold mb-6">חיפוש</h1>

      <form onSubmit={handleSearch} className="flex gap-2 mb-6">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="חפש..."
          className="flex-1 border rounded px-4 py-2"
        />
        <button
          type="submit"
          className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
        >
          חפש
        </button>
        {query && (
          <button
            type="button"
            onClick={handleClear}
            className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
          >
            נקה
          </button>
        )}
      </form>

      {query ? (
        <div>
          <p className="text-lg">
            מציג תוצאות עבור: <strong>{query}</strong>
          </p>
          <div className="mt-4 space-y-3">
            <div className="border p-4 rounded">תוצאה 1 עבור "{query}"</div>
            <div className="border p-4 rounded">תוצאה 2 עבור "{query}"</div>
            <div className="border p-4 rounded">תוצאה 3 עבור "{query}"</div>
          </div>
        </div>
      ) : (
        <p className="text-gray-500">הכנס מילת חיפוש כדי להתחיל</p>
      )}
    </main>
  );
}
  • השתמשנו ב-URLSearchParams כדי לבנות את ה-query string
  • router.push מעדכן את ה-URL בלי לרענן את הדף
  • כפתור "נקה" מנווט לנתיב בלי פרמטרים

תשובות לשאלות

  1. ההבדל בין layout.tsx ל-template.tsx: שניהם עוטפים דפים, אבל layout לא מתרנדר מחדש בניווט ושומר על state. לעומת זאת, template יוצר instance חדש בכל ניווט - מתאים לאנימציות כניסה או למצבים שצריכים איפוס.

  2. למה error.tsx חייב להיות Client Component: כי הוא משתמש ב-hooks (מקבל error ו-reset כ-props) וצריך להגיב לאינטראקציות משתמש (לחיצה על "נסה שוב"). React Error Boundaries עובדים רק כ-Client Components.

  3. יתרון Link על תגית a רגילה: Link מבצע client-side navigation - רק התוכן שהשתנה מתעדכן, בלי לטעון את כל הדף מחדש. בנוסף, הוא עושה prefetch אוטומטי לדפים שהקישור מצביע אליהם, מה שהופך את הניווט למהיר יותר.

  4. Route Groups ומתי נשתמש בהם: Route Groups הם תיקיות בסוגריים שלא משפיעות על הנתיב. נשתמש בהם כשרוצים ארגון לוגי של קבצים (למשל הפרדה בין חלק שיווקי לחלק אפליקטיבי), או כשרוצים לייאאוטים שונים לקבוצות דפים שונות.

  5. ההבדל בין router.push ל-router.replace: push מוסיף ערך חדש ל-history - המשתמש יכול ללחוץ "חזרה" ולהגיע לדף הקודם. replace מחליף את הערך הנוכחי - המשתמש לא יכול לחזור. replace מתאים למצבים כמו redirect אחרי login.