# מפרט Frontend — אתר ניהול מתכונים (Next.js App Router + TS + Tailwind, RTL)

---

## 1. עקרונות ארכיטקטורה

- **Server Components כברירת מחדל** לכל מה שהוא הצגת נתונים (SEO, TTFB, פחות JS ללקוח) — עמוד מתכון, דף תוצאות, דף בית.
- **Client Components** רק היכן שיש אינטראקציה: חיפוש, פאנל מסננים, שינוי כמויות, אשף היצירה, דיונים, פרופיל (טפסים).
- **React Query (TanStack Query)** בצד הלקוח לכל mutation / polling / cache אופטימי (לייקים, תגובות, autosave באשף). ל-Server Components שימוש ב-`fetch` עם `next: { revalidate }` / `tags` בלבד — **לא** React Query בשרת.
- **Zustand** (קליל מ-Redux) ל-state גלובלי בצד לקוח שאינו server-cache: מצב אשף היצירה (draft רב-שלבי), מסננים פתוחים/סגורים במובייל, מצב מנות נבחר בעמוד מתכון (יכול גם local state, ראה בהמשך).
- **URL כמקור אמת** לחיפוש ומסננים (searchParams) — מאפשר שיתוף קישור, ניווט אחורה, ו-SSR ידידותי ל-SEO.

```
package deps עיקריים:
next@14+, react, typescript, tailwindcss,
@tanstack/react-query, zustand,
@dnd-kit/core + @dnd-kit/sortable (drag&drop לאשף),
react-hook-form + zod (טפסים/ולידציה),
next/image, framer-motion (אנימציות עדינות),
radix-ui primitives (Dialog, Tabs, Accordion, Slider - accessible by default)
```

---

## 2. מבנה תיקיות

```
app/
├─ layout.tsx                     # RootLayout: <html dir="rtl" lang="he">, Providers
├─ globals.css
├─ providers.tsx                  # QueryClientProvider, ThemeProvider
├─ page.tsx                       # דף בית (/)
├─ (marketing)/
│   └─ loading.tsx / error.tsx
├─ search/
│   ├─ page.tsx                   # דף תוצאות /search?q=...&cat=...&time=...
│   ├─ SearchClient.tsx           # קליינט: URL state, פאנל מסננים
│   └─ loading.tsx
├─ recipes/
│   ├─ [slug]/
│   │   ├─ page.tsx               # עמוד מתכון (Server Component, generateMetadata)
│   │   ├─ opengraph-image.tsx
│   │   └─ loading.tsx
│   └─ new/
│       ├─ page.tsx               # אשף יצירה - shell
│       └─ WizardClient.tsx
├─ profile/
│   ├─ page.tsx                   # פרופיל אישי (של המשתמש המחובר)
│   ├─ [username]/page.tsx        # פרופיל ציבורי
│   └─ settings/page.tsx
├─ api/
│   ├─ recipes/route.ts
│   ├─ recipes/[id]/comments/route.ts
│   └─ upload/route.ts            # presigned URL להעלאת תמונות
└─ not-found.tsx

components/
├─ ui/                            # design-system גנרי (Button, Input, Badge, Skeleton...)
├─ home/
│   ├─ NaturalSearchBar.tsx
│   ├─ RecommendedCarousel.tsx
│   ├─ CategoryGrid.tsx
│   └─ RecipeCardMini.tsx
├─ search/
│   ├─ ResultCard.tsx
│   ├─ MatchReasonBadge.tsx
│   ├─ FiltersPanel.tsx
│   ├─ FilterChip.tsx
│   └─ SortDropdown.tsx
├─ recipe/
│   ├─ RecipeGallery.tsx
│   ├─ IngredientsList.tsx
│   ├─ ServingsStepper.tsx
│   ├─ StepsList.tsx
│   ├─ StepItem.tsx
│   ├─ DiscussionThread.tsx
│   ├─ CommentNode.tsx (רקורסיבי)
│   └─ NutritionPanel.tsx
├─ wizard/
│   ├─ WizardStepper.tsx
│   ├─ steps/
│   │   ├─ Step1Basics.tsx
│   │   ├─ Step2Photos.tsx        # drag&drop
│   │   ├─ Step3Ingredients.tsx   # drag&drop סדר
│   │   ├─ Step4Steps.tsx         # drag&drop סדר שלבים
│   │   ├─ Step5NutritionTags.tsx
│   │   └─ Step6Review.tsx
│   ├─ AutosaveIndicator.tsx
│   └─ DraggableList.tsx (גנרי, @dnd-kit)
└─ profile/
    ├─ ProfileHeader.tsx
    ├─ MyRecipesGrid.tsx
    ├─ SavedCollections.tsx
    └─ AccountSettingsForm.tsx

lib/
├─ api/ (fetchers: getRecipe, searchRecipes, ...)
├─ queryClient.ts
├─ store/ (zustand: wizardStore, uiStore)
├─ hooks/ (useServings, useDebouncedSearch, useAutosave)
├─ schemas/ (zod)
└─ types/
```

### Routing מסוכם

| Route | סוג | תיאור |
|---|---|---|
| `/` | Server | דף בית |
| `/search?q=&category=&time=&difficulty=&diet=` | Server + Client shell | תוצאות + מסננים ב-URL |
| `/recipes/[slug]` | Server | עמוד מתכון מלא |
| `/recipes/new` | Client (מוגן auth) | אשף 6 שלבים |
| `/recipes/[slug]/edit` | Client | עריכה — אותו אשף במצב edit |
| `/profile`, `/profile/[username]` | Server + Client parts | פרופיל |

---

## 3. דף הבית (`/`)

### קומפוננטות
- `NaturalSearchBar` (Client) — שדה חיפוש בשפה טבעית ("משהו קליל עם עוף בלי גלוטן") + debounce + הצעות autocomplete.
- `RecommendedCarousel` (Server data, Client לגלילה) — "מומלץ בשבילך" (מבוסס היסטוריה/מועדפים).
- `CategoryGrid` — קטגוריות (עוגות, צמחוני, ארוחת בוקר...) עם אייקון/תמונה.
- `RecipeCardMini` — כרטיס קטן משותף (משמש גם בקרוסלה וגם ב-grid).

### דוגמת קוד — `NaturalSearchBar`

```tsx
// components/home/NaturalSearchBar.tsx
'use client';
import { useState, useTransition, useDeferredValue } from 'react';
import { useRouter } from 'next/navigation';
import { Search } from 'lucide-react';

export function NaturalSearchBar() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const [isPending, startTransition] = useTransition();
  const router = useRouter();

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!query.trim()) return;
    startTransition(() => {
      router.push(`/search?q=${encodeURIComponent(query)}`);
    });
  }

  return (
    <form
      onSubmit={handleSubmit}
      role="search"
      aria-label="חיפוש מתכונים בשפה טבעית"
      className="relative w-full max-w-2xl mx-auto"
    >
      <label htmlFor="natural-search" className="sr-only">
        חפשו מתכון, למשל: "משהו קליל עם עוף בלי גלוטן"
      </label>
      <input
        id="natural-search"
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder='נסו: "עוגה טבעונית בלי סוכר לחג"'
        className="w-full rounded-2xl border border-gray-300 py-4 pr-12 pl-4
                   text-lg focus:outline-none focus:ring-2 focus:ring-orange-500
                   dark:bg-gray-800 dark:border-gray-700"
      />
      <button
        type="submit"
        aria-label="חפש"
        className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500
                   hover:text-orange-600"
      >
        <Search className="w-6 h-6" aria-hidden />
      </button>
      {isPending && (
        <span className="sr-only" role="status">מחפש תוצאות…</span>
      )}
    </form>
  );
}
```

> שימו לב: `dir="rtl"` מוגדר ברמת `<html>`, כך שהאייקון ממוקם אוטומטית מימין (`right-3`) — מומלץ להשתמש ב-Tailwind logical properties (`ps-`, `pe-`, `me-`, `ms-`) במקום `pr-`/`pl-` כדי שהעיצוב יהיה RTL-safe מקצה לקצה.

### דוגמת קוד — `page.tsx` (דף בית, Server Component)

```tsx
// app/page.tsx
import { NaturalSearchBar } from '@/components/home/NaturalSearchBar';
import { RecommendedCarousel } from '@/components/home/RecommendedCarousel';
import { CategoryGrid } from '@/components/home/CategoryGrid';
import { getRecommendedRecipes, getCategories } from '@/lib/api/home';

export const revalidate = 300; // ISR - 5 דקות

export default async function HomePage() {
  const [recommended, categories] = await Promise.all([
    getRecommendedRecipes(),
    getCategories(),
  ]);

  return (
    <main className="flex flex-col gap-12 px-4 py-8">
      <section aria-label="חיפוש">
        <NaturalSearchBar />
      </section>
      <section aria-labelledby="recommended-heading">
        <h2 id="recommended-heading" className="text-2xl font-bold mb-4">
          מומלץ בשבילך
        </h2>
        <RecommendedCarousel recipes={recommended} />
      </section>
      <section aria-labelledby="categories-heading">
        <h2 id="categories-heading" className="text-2xl font-bold mb-4">
          קטגוריות
        </h2>
        <CategoryGrid categories={categories} />
      </section>
    </main>
  );
}
```

---

## 4. דף תוצאות (`/search`)

### דפוס: Server Component קורא searchParams → מביא נתונים → מעביר ל-Client רק את הצורך

```tsx
// app/search/page.tsx
import { searchRecipes } from '@/lib/api/search';
import { ResultsGrid } from '@/components/search/ResultsGrid';
import { FiltersPanel } from '@/components/search/FiltersPanel';

interface Props {
  searchParams: {
    q?: string; category?: string; time?: string;
    difficulty?: string; diet?: string; sort?: string; page?: string;
  };
}

export default async function SearchPage({ searchParams }: Props) {
  const results = await searchRecipes(searchParams); // Server fetch, כולל "match reasons"

  return (
    <div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-6 px-4 py-6">
      <FiltersPanel initialFilters={searchParams} />
      <div>
        <h1 className="text-xl mb-4">
          {results.total} תוצאות עבור "{searchParams.q ?? 'הכל'}"
        </h1>
        <ResultsGrid results={results.items} />
      </div>
    </div>
  );
}
```

### קומפוננטות
- `FiltersPanel` (Client) — Radix `Accordion`/`Checkbox`/`Slider`, כותב ל-URL (`router.push` עם `useSearchParams` + `URLSearchParams`), לא שומר state כפול.
- `ResultCard` — תמונה lazy, כותרת, "סיבת התאמה", תגיות (זמן, קושי, דיאטה).
- `MatchReasonBadge` — מסביר *למה* המתכון עלה (למשל "מכיל 4/5 מהמצרכים שחיפשת").
- `SortDropdown`.

### דוגמת קוד — `ResultCard` + `MatchReasonBadge`

```tsx
// components/search/ResultCard.tsx
import Image from 'next/image';
import Link from 'next/link';
import { MatchReasonBadge } from './MatchReasonBadge';
import { Clock, ChefHat } from 'lucide-react';

export interface ResultCardProps {
  slug: string;
  title: string;
  imageUrl: string;
  timeMinutes: number;
  difficulty: 'קל' | 'בינוני' | 'מאתגר';
  matchReason: string; // "כולל 4 מתוך 5 מרכיבים שחיפשת"
  matchScore: number;  // 0-100
}

export function ResultCard({
  slug, title, imageUrl, timeMinutes, difficulty, matchReason, matchScore,
}: ResultCardProps) {
  return (
    <Link
      href={`/recipes/${slug}`}
      className="group block rounded-xl overflow-hidden border border-gray-200
                 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-orange-500
                 transition"
    >
      <div className="relative aspect-[4/3] bg-gray-100">
        <Image
          src={imageUrl}
          alt="" // דקורטיבי - הכותרת שמתחת נותנת את ההקשר הטקסטואלי
          fill
          loading="lazy"
          sizes="(max-width: 768px) 100vw, 25vw"
          className="object-cover group-hover:scale-105 transition-transform"
        />
        <MatchReasonBadge score={matchScore} reason={matchReason} />
      </div>
      <div className="p-3">
        <h3 className="font-semibold line-clamp-2">{title}</h3>
        <div className="flex gap-3 text-sm text-gray-500 mt-2">
          <span className="flex items-center gap-1">
            <Clock className="w-4 h-4" aria-hidden /> {timeMinutes} דק'
          </span>
          <span className="flex items-center gap-1">
            <ChefHat className="w-4 h-4" aria-hidden /> {difficulty}
          </span>
        </div>
      </div>
    </Link>
  );
}
```

```tsx
// components/search/MatchReasonBadge.tsx
export function MatchReasonBadge({ score, reason }: { score: number; reason: string }) {
  return (
    <div
      className="absolute bottom-2 right-2 max-w-[90%] rounded-full bg-black/70
                 backdrop-blur-sm px-3 py-1 text-xs text-white"
      title={reason}
    >
      <span aria-hidden>✨ </span>
      <span>{reason}</span>
      <span className="sr-only">{` (התאמה ${score}%)`}</span>
    </div>
  );
}
```

### פאנל מסננים — כתיבה ל-URL בלי לאבד היסטוריית ניווט

```tsx
// components/search/FiltersPanel.tsx
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { useCallback } from 'react';

export function FiltersPanel({ initialFilters }: { initialFilters: Record<string, string | undefined> }) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const setFilter = useCallback((key: string, value: string | null) => {
    const params = new URLSearchParams(searchParams.toString());
    if (value) params.set(key, value); else params.delete(key);
    params.delete('page'); // איפוס עימוד בעת שינוי מסנן
    router.push(`${pathname}?${params.toString()}`, { scroll: false });
  }, [router, pathname, searchParams]);

  return (
    <aside aria-label="מסננים" className="space-y-4">
      <fieldset>
        <legend className="font-semibold mb-2">זמן הכנה</legend>
        {['עד 15 דק', 'עד 30 דק', 'עד שעה'].map((label, i) => (
          <label key={label} className="flex items-center gap-2 py-1 cursor-pointer">
            <input
              type="radio"
              name="time"
              defaultChecked={initialFilters.time === String(i)}
              onChange={() => setFilter('time', String(i))}
              className="accent-orange-600"
            />
            {label}
          </label>
        ))}
      </fieldset>
      {/* דיאטה, קושי, קטגוריה... אותו דפוס */}
    </aside>
  );
}
```

---

## 5. עמוד מתכון (`/recipes/[slug]`)

### מבנה: Server Component טוען את המתכון + `generateMetadata` ל-SEO; חלקים אינטראקטיביים (מנות, דיון) הם Client Components "עלים" (islands).

```tsx
// app/recipes/[slug]/page.tsx
import { getRecipeBySlug } from '@/lib/api/recipes';
import { RecipeGallery } from '@/components/recipe/RecipeGallery';
import { IngredientsList } from '@/components/recipe/IngredientsList';
import { StepsList } from '@/components/recipe/StepsList';
import { DiscussionThread } from '@/components/recipe/DiscussionThread';
import { notFound } from 'next/navigation';

export async function generateMetadata({ params }: { params: { slug: string } }) {
  const recipe = await getRecipeBySlug(params.slug);
  if (!recipe) return {};
  return { title: recipe.title, description: recipe.description };
}

export default async function RecipePage({ params }: { params: { slug: string } }) {
  const recipe = await getRecipeBySlug(params.slug);
  if (!recipe) notFound();

  return (
    <article className="max-w-4xl mx-auto px-4 py-6">
      <h1 className="text-3xl font-bold mb-4">{recipe.title}</h1>
      <RecipeGallery images={recipe.images} />

      <div className="grid md:grid-cols-[1fr_2fr] gap-8 mt-8">
        {/* מרכיבים - Client עלה בגלל שינוי כמויות */}
        <IngredientsList
          ingredients={recipe.ingredients}
          baseServings={recipe.servings}
        />
        <StepsList steps={recipe.steps} />
      </div>

      <section className="mt-12" aria-labelledby="discussion-heading">
        <h2 id="discussion-heading" className="text-2xl font-bold mb-4">
          דיון ותגובות ({recipe.commentCount})
        </h2>
        <DiscussionThread recipeId={recipe.id} initialComments={recipe.comments} />
      </section>
    </article>
  );
}
```

### `RecipeGallery` — Lazy loading, תמונה ראשונה priority

```tsx
// components/recipe/RecipeGallery.tsx
'use client';
import Image from 'next/image';
import { useState } from 'react';

export function RecipeGallery({ images }: { images: { url: string; alt: string }[] }) {
  const [active, setActive] = useState(0);

  return (
    <div>
      <div className="relative aspect-video rounded-xl overflow-hidden bg-gray-100">
        <Image
          src={images[active].url}
          alt={images[active].alt}
          fill
          priority={active === 0} // התמונה הראשונה בלבד נטענת מיידית (LCP)
          sizes="(max-width: 768px) 100vw, 800px"
          className="object-cover"
        />
      </div>
      {images.length > 1 && (
        <div role="tablist" aria-label="תמונות נוספות" className="flex gap-2 mt-3">
          {images.map((img, i) => (
            <button
              key={img.url}
              role="tab"
              aria-selected={i === active}
              aria-label={`תמונה ${i + 1} מתוך ${images.length}`}
              onClick={() => setActive(i)}
              className={`relative w-16 h-16 rounded-lg overflow-hidden border-2
                ${i === active ? 'border-orange-600' : 'border-transparent'}`}
            >
              <Image
                src={img.url}
                alt=""
                fill
                loading="lazy"
                sizes="64px"
                className="object-cover"
              />
            </button>
          ))}
        </div>
      )}
    </div>
  );
}
```

### `IngredientsList` + `ServingsStepper` — שינוי כמויות לפי מספר מנות

חישוב יחסי (scale factor), בלי לגעת בשרת:

```tsx
// components/recipe/IngredientsList.tsx
'use client';
import { useState, useMemo } from 'react';
import { ServingsStepper } from './ServingsStepper';

interface Ingredient {
  id: string;
  name: string;
  quantity: number;
  unit: string;
}

export function IngredientsList({
  ingredients, baseServings,
}: { ingredients: Ingredient[]; baseServings: number }) {
  const [servings, setServings] = useState(baseServings);
  const scale = servings / baseServings;

  const scaled = useMemo(
    () => ingredients.map((ing) => ({
      ...ing,
      displayQuantity: formatQuantity(ing.quantity * scale),
    })),
    [ingredients, scale]
  );

  return (
    <section aria-labelledby="ingredients-heading">
      <div className="flex items-center justify-between mb-3">
        <h2 id="ingredients-heading" className="text-xl font-bold">מרכיבים</h2>
        <ServingsStepper value={servings} onChange={setServings} min={1} max={24} />
      </div>
      <ul className="space-y-2" aria-live="polite">
        {scaled.map((ing) => (
          <li key={ing.id} className="flex justify-between border-b pb-1">
            <span>{ing.name}</span>
            <span className="font-medium tabular-nums">
              {ing.displayQuantity} {ing.unit}
            </span>
          </li>
        ))}
      </ul>
    </section>
  );
}

// עיגול "יפה" (1/4, 1/2, 3/4) לצגית מתכונים
function formatQuantity(n: number): string {
  const rounded = Math.round(n * 4) / 4;
  const whole = Math.floor(rounded);
  const frac = rounded - whole;
  const fracMap: Record<number, string> = { 0.25: '¼', 0.5: '½', 0.75: '¾' };
  return `${whole || ''}${fracMap[frac] ?? ''}`.trim() || '0';
}
```

```tsx
// components/recipe/ServingsStepper.tsx
'use client';

export function ServingsStepper({
  value, onChange, min = 1, max = 20,
}: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
  return (
    <div className="flex items-center gap-2" role="group" aria-label="מספר מנות">
      <button
        type="button"
        onClick={() => onChange(Math.max(min, value - 1))}
        disabled={value <= min}
        aria-label="הפחת מנה"
        className="w-8 h-8 rounded-full border disabled:opacity-40"
      >
        −
      </button>
      <span aria-live="polite" className="min-w-[3ch] text-center font-semibold">
        {value} מנות
      </span>
      <button
        type="button"
        onClick={() => onChange(Math.min(max, value + 1))}
        disabled={value >= max}
        aria-label="הוסף מנה"
        className="w-8 h-8 rounded-full border disabled:opacity-40"
      >
        +
      </button>
    </div>
  );
}
```

### `DiscussionThread` / `CommentNode` — דיון מקונן, React Query + optimistic update

```tsx
// components/recipe/DiscussionThread.tsx
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CommentNode } from './CommentNode';
import { fetchComments, postComment } from '@/lib/api/comments';
import { useState } from 'react';

export function DiscussionThread({
  recipeId, initialComments,
}: { recipeId: string; initialComments: Comment[] }) {
  const queryClient = useQueryClient();
  const [text, setText] = useState('');

  const { data: comments } = useQuery({
    queryKey: ['comments', recipeId],
    queryFn: () => fetchComments(recipeId),
    initialData: initialComments,
    staleTime: 30_000,
  });

  const mutation = useMutation({
    mutationFn: (payload: { text: string; parentId?: string }) =>
      postComment(recipeId, payload),
    onMutate: async (payload) => {
      await queryClient.cancelQueries({ queryKey: ['comments', recipeId] });
      const previous = queryClient.getQueryData<Comment[]>(['comments', recipeId]);
      const optimistic: Comment = {
        id: `temp-${crypto.randomUUID()}`,
        text: payload.text,
        parentId: payload.parentId ?? null,
        author: { name: 'אני', avatarUrl: '' },
        replies: [],
        createdAt: new Date().toISOString(),
        pending: true,
      };
      queryClient.setQueryData<Comment[]>(['comments', recipeId], (old) =>
        insertComment(old ?? [], optimistic));
      return { previous };
    },
    onError: (_err, _payload, context) => {
      if (context?.previous) {
        queryClient.setQueryData(['comments', recipeId], context.previous);
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['comments', recipeId] });
    },
  });

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (!text.trim()) return;
          mutation.mutate({ text });
          setText('');
        }}
        className="mb-6"
      >
        <label htmlFor="new-comment" className="sr-only">הוספת תגובה</label>
        <textarea
          id="new-comment"
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="שאלה או תגובה על המתכון..."
          className="w-full border rounded-lg p-3"
          rows={3}
        />
        <button type="submit" className="mt-2 btn-primary" disabled={mutation.isPending}>
          פרסום
        </button>
      </form>

      <ul className="space-y-4">
        {comments?.map((comment) => (
          <CommentNode
            key={comment.id}
            comment={comment}
            depth={0}
            onReply={(parentId, replyText) => mutation.mutate({ text: replyText, parentId })}
          />
        ))}
      </ul>
    </div>
  );
}
```

```tsx
// components/recipe/CommentNode.tsx - רקורסיבי, עומק מוגבל ל-3 עם "הצג עוד תגובות"
'use client';
import { useState } from 'react';

const MAX_VISUAL_DEPTH = 3;

export function CommentNode({
  comment, depth, onReply,
}: { comment: Comment; depth: number; onReply: (parentId: string, text: string) => void }) {
  const [replying, setReplying] = useState(false);
  const [replyText, setReplyText] = useState('');
  const indent = Math.min(depth, MAX_VISUAL_DEPTH);

  return (
    <li
      style={{ marginInlineStart: `${indent * 1.5}rem` }} // logical property - RTL-safe
      className={comment.pending ? 'opacity-60' : ''}
      aria-busy={comment.pending}
    >
      <div className="border-s-2 border-gray-200 ps-3">
        <div className="flex items-center gap-2 text-sm text-gray-500">
          <span className="font-semibold text-gray-800">{comment.author.name}</span>
          <time dateTime={comment.createdAt}>
            {new Intl.DateTimeFormat('he-IL').format(new Date(comment.createdAt))}
          </time>
        </div>
        <p className="mt-1">{comment.text}</p>
        <button
          onClick={() => setReplying((v) => !v)}
          aria-expanded={replying}
          className="text-sm text-orange-600 mt-1"
        >
          {replying ? 'ביטול' : 'הגב'}
        </button>

        {replying && (
          <form
            onSubmit={(e) => {
              e.preventDefault();
              onReply(comment.id, replyText);
              setReplyText('');
              setReplying(false);
            }}
            className="mt-2"
          >
            <label htmlFor={`reply-${comment.id}`} className="sr-only">תגובה ל{comment.author.name}</label>
            <textarea
              id={`reply-${comment.id}`}
              value={replyText}
              onChange={(e) => setReplyText(e.target.value)}
              className="w-full border rounded p-2 text-sm"
              rows={2}
              autoFocus
            />
            <button type="submit" className="text-sm btn-primary mt-1">שליחה</button>
          </form>
        )}
      </div>

      {comment.replies.length > 0 && (
        <ul className="mt-2 space-y-2">
          {comment.replies.map((child) => (
            <CommentNode key={child.id} comment={child} depth={depth + 1} onReply={onReply} />
          ))}
        </ul>
      )}
    </li>
  );
}
```

---

## 6. אשף יצירת מתכון — 6 שלבים, autosave, drag&drop

### עקרונות
- **State גלובלי** ב-Zustand (`wizardStore`) — נשמר גם ל-`localStorage` (persist middleware) כדי לשרוד רענון עמוד.
- **Autosave**: `useAutosave` hook — debounce 1.5 שניות אחרי כל שינוי → `useMutation` ל-`PATCH /api/recipes/draft/:id`.
- **Drag&Drop**: `@dnd-kit/core` + `@dnd-kit/sortable` — לתמונות (סדר גלריה), מרכיבים (סדר), שלבים (סדר).
- **ניווט בין שלבים**: לא מאבד מידע (כל השלבים באותו state), ולידציה per-step עם zod לפני מעבר קדימה.

```tsx
// lib/store/wizardStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface WizardState {
  step: number;
  draftId: string | null;
  basics: { title: string; description: string; category: string; servings: number };
  photos: { id: string; url: string; file?: File }[];
  ingredients: { id: string; name: string; quantity: number; unit: string }[];
  steps: { id: string; text: string; imageUrl?: string }[];
  nutritionTags: { tags: string[]; calories?: number };
  saveStatus: 'idle' | 'saving' | 'saved' | 'error';

  setStep: (s: number) => void;
  updateBasics: (patch: Partial<WizardState['basics']>) => void;
  reorderPhotos: (photos: WizardState['photos']) => void;
  reorderIngredients: (items: WizardState['ingredients']) => void;
  reorderSteps: (items: WizardState['steps']) => void;
  setSaveStatus: (s: WizardState['saveStatus']) => void;
  reset: () => void;
}

const initialData = {
  step: 1, draftId: null,
  basics: { title: '', description: '', category: '', servings: 4 },
  photos: [], ingredients: [], steps: [],
  nutritionTags: { tags: [] },
  saveStatus: 'idle' as const,
};

export const useWizardStore = create<WizardState>()(
  persist(
    (set) => ({
      ...initialData,
      setStep: (step) => set({ step }),
      updateBasics: (patch) => set((s) => ({ basics: { ...s.basics, ...patch } })),
      reorderPhotos: (photos) => set({ photos }),
      reorderIngredients: (ingredients) => set({ ingredients }),
      reorderSteps: (steps) => set({ steps }),
      setSaveStatus: (saveStatus) => set({ saveStatus }),
      reset: () => set(initialData),
    }),
    { name: 'recipe-wizard-draft' } // localStorage key
  )
);
```

```tsx
// lib/hooks/useAutosave.ts
import { useEffect, useRef } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useWizardStore } from '@/lib/store/wizardStore';
import { saveDraft } from '@/lib/api/recipes';

export function useAutosave() {
  const state = useWizardStore();
  const setSaveStatus = useWizardStore((s) => s.setSaveStatus);
  const timer = useRef<ReturnType<typeof setTimeout>>();

  const mutation = useMutation({
    mutationFn: saveDraft,
    onMutate: () => setSaveStatus('saving'),
    onSuccess: (res) => {
      setSaveStatus('saved');
      if (!state.draftId) useWizardStore.setState({ draftId: res.id });
    },
    onError: () => setSaveStatus('error'),
  });

  useEffect(() => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      mutation.mutate({
        id: state.draftId,
        basics: state.basics,
        ingredients: state.ingredients,
        steps: state.steps,
        photos: state.photos.map(({ id, url }) => ({ id, url })),
        nutritionTags: state.nutritionTags,
      });
    }, 1500);
    return () => clearTimeout(timer.current);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [state.basics, state.ingredients, state.steps, state.photos, state.nutritionTags]);
}
```

```tsx
// components/wizard/AutosaveIndicator.tsx
'use client';
import { useWizardStore } from '@/lib/store/wizardStore';

const LABELS = {
  idle: '', saving: 'שומר…', saved: 'נשמר ✓', error: 'שגיאה בשמירה',
};

export function AutosaveIndicator() {
  const status = useWizardStore((s) => s.saveStatus);
  return (
    <span role="status" aria-live="polite" className="text-sm text-gray-500">
      {LABELS[status]}
    </span>
  );
}
```

### `DraggableList` גנרי (@dnd-kit) — משמש לתמונות, מרכיבים, שלבים

```tsx
// components/wizard/DraggableList.tsx
'use client';
import {
  DndContext, closestCenter, KeyboardSensor, PointerSensor,
  useSensor, useSensors,
} from '@dnd-kit/core';
import {
  SortableContext, verticalListSortingStrategy, useSortable,
  sortableKeyboardCoordinates, arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

interface Item { id: string }

export function DraggableList<T extends Item>({
  items, onReorder, renderItem,
}: {
  items: T[];
  onReorder: (items: T[]) => void;
  renderItem: (item: T, index: number) => React.ReactNode;
}) {
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  );

  function handleDragEnd(event: any) {
    const { active, over } = event;
    if (!over || active.id === over.id) return;
    const oldIndex = items.findIndex((i) => i.id === active.id);
    const newIndex = items.findIndex((i) => i.id === over.id);
    onReorder(arrayMove(items, oldIndex, newIndex));
  }

  return (
    <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
      <SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
        <ul className="space-y-2">
          {items.map((item, index) => (
            <SortableRow key={item.id} id={item.id}>
              {renderItem(item, index)}
            </SortableRow>
          ))}
        </ul>
      </SortableContext>
    </DndContext>
  );
}

function SortableRow({ id, children }: { id: string; children: React.ReactNode }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
    useSortable({ id });

  return (
    <li
      ref={setNodeRef}
      style={{ transform: CSS.Transform.toString(transform), transition }}
      className={`flex items-center gap-2 bg-white border rounded-lg p-2
        ${isDragging ? 'shadow-lg opacity-80' : ''}`}
    >
      <button
        {...attributes}
        {...listeners}
        aria-label="גרור לשינוי סדר"
        className="cursor-grab touch-none text-gray-400 px-2"
      >
        ⠿
      </button>
      <div className="flex-1">{children}</div>
    </li>
  );
}
```

> נגישות ל-drag&drop: `@dnd-kit` תומך ב-`KeyboardSensor` באופן מובנה — משתמשי מקלדת יכולים לבחור פריט (Space/Enter) ולהזיז עם חצים. חשוב לשמור על `aria-label` ברור על ידית הגרירה, ולהוסיף `aria-live` שמכריז "פריט הועבר למיקום X" (ניתן דרך `announcements` prop של `DndContext`).

### `WizardStepper` + שלד האשף

```tsx
// components/wizard/WizardStepper.tsx
'use client';
import { useWizardStore } from '@/lib/store/wizardStore';

const STEPS = ['פרטים בסיסיים', 'תמונות', 'מרכיבים', 'שלבי הכנה', 'תזונה ותגיות', 'סקירה ופרסום'];

export function WizardStepper() {
  const { step, setStep } = useWizardStore();

  return (
    <nav aria-label="שלבי יצירת מתכון">
      <ol className="flex gap-2 overflow-x-auto">
        {STEPS.map((label, i) => {
          const num = i + 1;
          const isCurrent = step === num;
          return (
            <li key={label} className="flex-1 min-w-[100px]">
              <button
                onClick={() => num < step && setStep(num)} // אפשר לחזור אחורה, לא לדלג קדימה
                aria-current={isCurrent ? 'step' : undefined}
                disabled={num > step}
                className={`w-full text-xs sm:text-sm py-2 border-b-4 transition-colors
                  ${isCurrent ? 'border-orange-600 font-bold' : 'border-gray-200'}
                  ${num > step ? 'text-gray-300' : 'text-gray-700'}`}
              >
                {num}. {label}
              </button>
            </li>
          );
        })}
      </ol>
    </nav>
  );
}
```

```tsx
// app/recipes/new/WizardClient.tsx
'use client';
import { useWizardStore } from '@/lib/store/wizardStore';
import { useAutosave } from '@/lib/hooks/useAutosave';
import { WizardStepper } from '@/components/wizard/WizardStepper';
import { AutosaveIndicator } from '@/components/wizard/AutosaveIndicator';
import Step1Basics from '@/components/wizard/steps/Step1Basics';
import Step2Photos from '@/components/wizard/steps/Step2Photos';
import Step3Ingredients from '@/components/wizard/steps/Step3Ingredients';
import Step4Steps from '@/components/wizard/steps/Step4Steps';
import Step5NutritionTags from '@/components/wizard/steps/Step5NutritionTags';
import Step6Review from '@/components/wizard/steps/Step6Review';

const STEP_COMPONENTS = [
  Step1Basics, Step2Photos, Step3Ingredients, Step4Steps, Step5NutritionTags, Step6Review,
];

export function WizardClient() {
  useAutosave();
  const { step, setStep } = useWizardStore();
  const StepComponent = STEP_COMPONENTS[step - 1];

  return (
    <div className="max-w-3xl mx-auto px-4 py-6">
      <div className="flex items-center justify-between mb-4">
        <h1 className="text-2xl font-bold">מתכון חדש</h1>
        <AutosaveIndicator />
      </div>
      <WizardStepper />
      <div className="mt-6" role="tabpanel" aria-label={`שלב ${step}`}>
        <StepComponent />
      </div>
      <div className="flex justify-between mt-8">
        <button
          onClick={() => setStep(Math.max(1, step - 1))}
          disabled={step === 1}
          className="btn-secondary"
        >
          הקודם
        </button>
        <button
          onClick={() => setStep(Math.min(6, step + 1))}
          disabled={step === 6}
          className="btn-primary"
        >
          הבא
        </button>
      </div>
    </div>
  );
}
```

### שלב תמונות עם drag&drop + drop-zone

```tsx
// components/wizard/steps/Step2Photos.tsx
'use client';
import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone'; // או מימוש native onDrop
import { useWizardStore } from '@/lib/store/wizardStore';
import { DraggableList } from '@/components/wizard/DraggableList';
import { uploadImage } from '@/lib/api/upload';
import Image from 'next/image';

export default function Step2Photos() {
  const { photos, reorderPhotos } = useWizardStore();

  const onDrop = useCallback(async (files: File[]) => {
    const newItems = files.map((file) => ({
      id: crypto.randomUUID(), url: URL.createObjectURL(file), file,
    }));
    reorderPhotos([...photos, ...newItems]);
    // העלאה בפועל ברקע, מחליף blob URL ב-URL אמיתי בסיום
    for (const item of newItems) {
      const uploaded = await uploadImage(item.file!);
      useWizardStore.setState((s) => ({
        photos: s.photos.map((p) => (p.id === item.id ? { ...p, url: uploaded.url } : p)),
      }));
    }
  }, [photos, reorderPhotos]);

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop, accept: { 'image/*': [] },
  });

  return (
    <section aria-labelledby="photos-heading">
      <h2 id="photos-heading" className="text-xl font-bold mb-3">תמונות המתכון</h2>
      <div
        {...getRootProps()}
        className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer
          ${isDragActive ? 'border-orange-600 bg-orange-50' : 'border-gray-300'}`}
      >
        <input {...getInputProps()} aria-label="העלאת תמונות מתכון" />
        <p>גררו תמונות לכאן או לחצו לבחירה</p>
      </div>

      <div className="mt-4">
        <DraggableList
          items={photos}
          onReorder={reorderPhotos}
          renderItem={(photo) => (
            <div className="flex items-center gap-3">
              <div className="relative w-16 h-16 rounded overflow-hidden">
                <Image src={photo.url} alt="" fill className="object-cover" />
              </div>
              <span className="text-sm text-gray-500">
                {photo === photos[0] ? 'תמונה ראשית' : ''}
              </span>
            </div>
          )}
        />
      </div>
    </section>
  );
}
```

---

## 7. פרופיל משתמש

### קומפוננטות
- `ProfileHeader` (Server) — תמונת פרופיל, שם, סטטיסטיקות (מספר מתכונים/עוקבים).
- `MyRecipesGrid` (Server data + Client actions לעריכה/מחיקה).
- `SavedCollections` — אוספים/מועדפים, Tabs (Radix).
- `AccountSettingsForm` (Client) — react-hook-form + zod, שמירה עם React Query mutation + toast.

```tsx
// app/profile/page.tsx
import { getCurrentUser } from '@/lib/api/auth';
import { getUserRecipes, getUserCollections } from '@/lib/api/profile';
import { ProfileHeader } from '@/components/profile/ProfileHeader';
import { ProfileTabs } from '@/components/profile/ProfileTabs';
import { redirect } from 'next/navigation';

export default async function ProfilePage() {
  const user = await getCurrentUser();
  if (!user) redirect('/login');

  const [recipes, collections] = await Promise.all([
    getUserRecipes(user.id),
    getUserCollections(user.id),
  ]);

  return (
    <main className="max-w-5xl mx-auto px-4 py-8">
      <ProfileHeader user={user} />
      <ProfileTabs recipes={recipes} collections={collections} />
    </main>
  );
}
```

```tsx
// components/profile/AccountSettingsForm.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation } from '@tanstack/react-query';
import { updateProfile } from '@/lib/api/profile';

const schema = z.object({
  displayName: z.string().min(2, 'שם קצר מדי').max(50),
  bio: z.string().max(300).optional(),
  dietaryPreferences: z.array(z.string()).optional(),
});
type FormValues = z.infer<typeof schema>;

export function AccountSettingsForm({ initial }: { initial: FormValues }) {
  const { register, handleSubmit, formState: { errors, isSubmitting } } =
    useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: initial });

  const mutation = useMutation({ mutationFn: updateProfile });

  return (
    <form
      onSubmit={handleSubmit((data) => mutation.mutate(data))}
      className="space-y-4"
      noValidate
    >
      <div>
        <label htmlFor="displayName" className="block font-medium mb-1">שם תצוגה</label>
        <input
          id="displayName"
          {...register('displayName')}
          aria-invalid={!!errors.displayName}
          aria-describedby={errors.displayName ? 'displayName-error' : undefined}
          className="w-full border rounded-lg p-2"
        />
        {errors.displayName && (
          <p id="displayName-error" role="alert" className="text-red-600 text-sm mt-1">
            {errors.displayName.message}
          </p>
        )}
      </div>
      <button type="submit" disabled={isSubmitting} className="btn-primary">
        {isSubmitting ? 'שומר…' : 'שמירה'}
      </button>
      {mutation.isSuccess && (
        <p role="status" className="text-green-600 text-sm">הפרטים נשמרו בהצלחה</p>
      )}
    </form>
  );
}
```

---

## 8. State Management — סיכום החלטות

| תחום | פתרון | נימוק |
|---|---|---|
| נתוני שרת (מתכונים, תוצאות חיפוש) | Server Components + `fetch` cache/ISR | SEO, אין hydration מיותר |
| נתוני שרת דינמיים בצד לקוח (תגובות, לייקים) | React Query | cache, optimistic updates, refetch-on-focus |
| מסננים/חיפוש | URL (`searchParams`) | שיתוף קישור, back/forward, SSR |
| אשף יצירה (רב-שלבי, מורכב) | Zustand + persist(localStorage) | שורד רענון, לא תלוי בעץ קומפוננטות, נגיש מכל שלב |
| UI מקומי (accordion פתוח, טאב פעיל) | `useState` מקומי | לא צריך שיתוף גלובלי |
| טפסים | react-hook-form + zod | ולידציה, ביצועים (uncontrolled) |

### Providers גלובליים

```tsx
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  const [client] = useState(() => new QueryClient({
    defaultOptions: {
      queries: { staleTime: 60_000, refetchOnWindowFocus: false, retry: 1 },
    },
  }));
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
```

```tsx
// app/layout.tsx
import { Providers } from './providers';
import './globals.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="he" dir="rtl">
      <body>
        <Providers>
          <a href="#main-content" className="skip-link">דלג לתוכן הראשי</a>
          {children}
        </Providers>
      </body>
    </html>
  );
}
```

---

## 9. נגישות (a11y) — checklist מיושם לאורך הקומפוננטות

- `dir="rtl"` + `lang="he"` ברמת `<html>`; שימוש ב-Tailwind logical properties (`ms-`, `me-`, `ps-`, `pe-`, `start-`, `end-`) ולא `left/right` קשיחים.
- כל אלמנט אינטראקטיבי הוא `<button>`/`<a>` אמיתי, לא `<div onClick>`.
- `aria-label`/`aria-labelledby` לכל אזור (`section`, `nav`, `aside`).
- `aria-live="polite"` לעדכונים דינמיים: תוצאות חיפוש, סטטוס autosave, ספירת מנות, הוספת תגובה.
- ניגודיות צבעים AA לפחות (בדוק כתום/לבן), focus ring גלוי (`focus-visible:ring-2`) — לא מוסתר.
- טפסים: `label` מקושר, `aria-invalid` + `aria-describedby` להודעות שגיאה, `role="alert"`.
- Drag&drop: `KeyboardSensor` חלופי מלא (dnd-kit), הכרזות `announcements` ל-screen reader.
- תמונות: `alt=""` לתמונות דקורטיביות (טקסט שכבר מוצג לצד), `alt` תיאורי לתמונות מידעיות (בגלריית המתכון).
- ניווט מקלדת מלא באשף (Tab/Shift+Tab בין שדות, לא נלכד ב-modal ללא מוצא).
- `skip-link` לתוכן ראשי.
- גדלי מגע (touch target) ≥ 44px לכפתורי stepper/דראג.

---

## 10. Lazy loading לתמונות — כללי אצבע

1. **תמונה ראשונה מעל הקיפול** (הירו בעמוד מתכון, כרטיס ראשון בקרוסלה) → `priority` + ללא `loading="lazy"` (למניעת פגיעה ב-LCP).
2. **כל השאר** → `loading="lazy"` (ברירת מחדל של `next/image` ממילא, אך מפורש לבהירות) + `sizes` נכון לפי breakpoint כדי שהדפדפן יבחר את גודל התמונה המתאים מ-`srcSet`.
3. תמונות דקורטיביות בכרטיסים → `alt=""`.
4. שימוש ב-`placeholder="blur"` + `blurDataURL` (נוצר בזמן upload/build) לחוויית טעינה חלקה.
5. `next.config.js` — הגדרת `images.remotePatterns` לדומיין ה-CDN, ו-`formats: ['image/avif', 'image/webp']`.

```js
// next.config.js
module.exports = {
  images: {
    remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }],
    formats: ['image/avif', 'image/webp'],
  },
};
```

---

## 11. Data Fetching — טבלת מיפוי מסך → אסטרטגיה

| מסך | טעינה ראשונית | עדכון/אינטראקציה |
|---|---|---|
| דף בית | Server fetch + ISR (`revalidate: 300`) | — |
| תוצאות חיפוש | Server fetch לפי `searchParams` (SSR מלא, לא cache) | מסננים → `router.push` → Server מתרנדר מחדש |
| עמוד מתכון | Server fetch, `generateMetadata` | מנות: client state; תגובות: React Query + optimistic |
| אשף יצירה | Zustand (client-only, אין fetch ראשוני אלא אם עריכה — אז `getRecipeDraft` בטעינה) | Autosave (`useMutation` + debounce) |
| פרופיל | Server fetch למידע סטטי | טפסים/הגדרות: React Query mutations |

זהו המפרט המלא — ניתן להרחיב כל חלק (data schema/DB, i18n, טסטים) לפי צורך.
