# Blogs API Integration Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Wire the news list, create/edit form, and table actions (delete, toggle featured) to the real dashboard API, and replace the Lexical editor with the existing TipTap editor.

**Architecture:** Create `hooks/use-news-data.ts` following the established `use-featured-news-data.ts` pattern (SWR + `authApi` + mock fallback), update the container/table/view to use it, fix the upsert form to use `authApi` with proper edit hydration via `GET /news/{id}`, and add a controlled `RichTextEditor` export to the existing TipTap component file.

**Tech Stack:** Next.js 15 App Router, React 19, SWR, Axios (`authApi`), TipTap v3, react-hook-form, shadcn/ui, next-intl.

---

## File Map

| File | Action |
|---|---|
| `hooks/use-news-data.ts` | **Create** — `NewsItem` type, `useNewsList`, `useNewsDetail`, `useNewsMutations` |
| `components/tiptap/rich-text-editor.tsx` | **Modify** — add controlled `RichTextEditor` export (keeps existing `RichTextEditorDemo`) |
| `components/dashboard/blogs/blogs-types.ts` | **Modify** — remove `DashboardBlogItem`, keep `DashboardBlogStatus` + `LocalizedText` |
| `components/dashboard/blogs/blogs-table.tsx` | **Modify** — accept `NewsItem[]`, add `onDelete` prop, remove "change status" action |
| `components/dashboard/blogs/blogs-page-view.tsx` | **Modify** — accept `NewsItem[]`, thread `onDelete` prop |
| `components/dashboard/blogs/blogs-page-container.tsx` | **Modify** — replace mock hook with `useNewsList`, wire delete AlertDialog, wire toggle featured |
| `components/dashboard/blogs/blog-upsert-form.tsx` | **Modify** — switch to `authApi`, add `useNewsDetail` hydration, fix category/tag SWR keys, remove legacy `initialData` prop |
| `components/dashboard/blogs/blog-upsert-form-view.tsx` | **Modify** — swap `LexicalRichTextEditor` → `RichTextEditor` (TipTap) |
| `app/[locale]/(dashboard)/dashboard/blogs/[id]/edit/page.tsx` | **Modify** — remove `getDashboardBlogById` call, pass `blogId={id}` |

---

## Task 1: Add controlled `RichTextEditor` to TipTap

**Files:**
- Modify: `components/tiptap/rich-text-editor.tsx`

- [ ] **Step 1: Add `useEffect` import and the controlled component**

Open `components/tiptap/rich-text-editor.tsx`. Add `useEffect` to the React imports at the top of the file (there is no existing React import — add one), then append the following export after the closing brace of `RichTextEditorDemo`:

```tsx
import { useEffect } from "react";
```

Add this at the **end** of the file (after the existing `RichTextEditorDemo` function):

```tsx
interface RichTextEditorProps {
  value: string;
  onChange: (value: string) => void;
  dir?: "rtl" | "ltr";
  className?: string;
}

export function RichTextEditor({
  value,
  onChange,
  dir,
  className,
}: RichTextEditorProps) {
  const editor = useEditor({
    immediatelyRender: false,
    extensions: extensions as Extension[],
    content: value,
    editorProps: {
      attributes: {
        class: "max-w-full focus:outline-none",
        ...(dir ? { dir } : {}),
      },
    },
    onUpdate: ({ editor }) => {
      onChange(editor.getHTML());
    },
  });

  // Sync when the form resets (e.g. after edit hydration sets new content)
  useEffect(() => {
    if (!editor) return;
    if (editor.getHTML() !== value) {
      editor.commands.setContent(value || "", false);
    }
  }, [editor, value]);

  if (!editor) return null;

  return (
    <div
      className={cn(
        "relative w-full overflow-hidden overflow-y-scroll border bg-card pb-[60px] sm:pb-0",
        className
      )}
    >
      <EditorToolbar editor={editor} />
      <FloatingToolbar editor={editor} />
      <TipTapFloatingMenu editor={editor} />
      <EditorContent
        editor={editor}
        className="min-h-[300px] w-full min-w-full cursor-text sm:p-6"
        dir={dir}
      />
    </div>
  );
}
```

- [ ] **Step 2: Verify build compiles**

```bash
npm run build 2>&1 | tail -20
```

Expected: no TypeScript errors related to `rich-text-editor.tsx`.

- [ ] **Step 3: Commit**

```bash
git add components/tiptap/rich-text-editor.tsx
git commit -m "feat(tiptap): add controlled RichTextEditor export"
```

---

## Task 2: Create `hooks/use-news-data.ts`

**Files:**
- Create: `hooks/use-news-data.ts`

- [ ] **Step 1: Create the file with types, mapper, and `useNewsList`**

Create `hooks/use-news-data.ts` with the following content:

```ts
"use client";

import type { FilterStatus } from "@/components/ui/filter/filter-button";
import { authApi } from "@/swr/auth-config";
import { AxiosError } from "axios";
import { useCallback, useMemo, useState } from "react";
import useSWR, { useSWRConfig } from "swr";

// ─── Raw API shape ────────────────────────────────────────────────────────────

type RawTranslation =
  | Array<{ locale?: string; title?: string; sub_title?: string; description?: string; meta_title?: string; meta_description?: string }>
  | { ar?: { title?: string; sub_title?: string; description?: string; meta_title?: string; meta_description?: string }; en?: { title?: string; sub_title?: string; description?: string; meta_title?: string; meta_description?: string } };

interface RawNewsItem {
  id?: number | string;
  translations?: RawTranslation;
  title?: string | { ar?: string; en?: string };
  source?: string;
  slug?: string;
  direction?: "rtl" | "ltr";
  is_featured?: boolean | number | string;
  is_published?: boolean | number | string;
  status?: string | number;
  status_text?: string;
  category?: { id?: number | string; translations?: RawTranslation; name?: string | { ar?: string; en?: string } } | { ar?: string; en?: string };
  category_id?: number | string;
  sub_category_id?: number | string;
  tags?: Array<{ id?: number | string }>;
  main_image?: string | null;
  meta_image?: string | null;
  image_description?: string;
  schudle_date?: string | null;
  schudle_time?: string | null;
  schedule_date?: string | null;
  schedule_time?: string | null;
  views?: number | null;
  views_count?: number | null;
}

interface RawListResponse {
  list?: RawNewsItem[];
  data?: RawNewsItem[] | { list?: RawNewsItem[] };
  paginator?: { total_count?: number; current_page?: number; per_page?: number };
  meta?: { total?: number };
  total?: number;
}

// ─── Normalised view shape ────────────────────────────────────────────────────

export interface NewsItem {
  // Table display
  id: string;
  title: { ar: string; en: string };
  image: string;
  imageAlt: { ar: string; en: string };
  category: { ar: string; en: string };
  status: FilterStatus;
  featured: boolean;
  publishDate: string | null;
  publishTime: string | null;
  views: number;
  // Form edit fields
  titleAr: string;
  titleEn: string;
  subTitleAr: string;
  subTitleEn: string;
  descriptionAr: string;
  descriptionEn: string;
  metaTitleAr: string;
  metaTitleEn: string;
  metaDescriptionAr: string;
  metaDescriptionEn: string;
  source: string;
  slug: string;
  direction: "rtl" | "ltr";
  isFeatured: boolean;
  categoryId: string;
  subCategoryId: string;
  tags: string[];
  mainImageUrl: string | null;
  metaImageUrl: string | null;
  scheduleDate: string | null;
  scheduleTime: string | null;
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

const toBool = (v: unknown): boolean =>
  v === true || v === 1 || v === "1" || v === "true";

const STATUS_MAP: Record<string, FilterStatus> = {
  published: "published",
  draft: "draft",
  scheduled: "scheduled",
  disabled: "disabled",
  "1": "published",
  "2": "draft",
  "3": "scheduled",
  "4": "disabled",
};

const indexTranslations = (
  raw: RawTranslation | undefined
): Record<string, { title?: string; sub_title?: string; description?: string; meta_title?: string; meta_description?: string }> => {
  if (!raw) return {};
  if (Array.isArray(raw)) {
    return Object.fromEntries(raw.map((t) => [t.locale ?? "ar", t]));
  }
  return raw as Record<string, { title?: string; sub_title?: string; description?: string; meta_title?: string; meta_description?: string }>;
};

const pickLocale = (
  t: Record<string, { title?: string; sub_title?: string; description?: string; meta_title?: string; meta_description?: string }>,
  locale: "ar" | "en"
) => t[locale] ?? t.ar ?? t.en ?? {};

const pickCategoryName = (
  cat: RawNewsItem["category"],
  locale: "ar" | "en"
): string => {
  if (!cat) return "";
  if ("ar" in cat || "en" in cat) {
    return (locale === "ar" ? (cat as { ar?: string }).ar : (cat as { en?: string }).en) ?? "";
  }
  const c = cat as { translations?: RawTranslation; name?: string | { ar?: string; en?: string } };
  if (c.translations) {
    const idx = indexTranslations(c.translations);
    return pickLocale(idx, locale).title ?? "";
  }
  if (typeof c.name === "object" && c.name) return c.name[locale] ?? c.name.ar ?? "";
  if (typeof c.name === "string") return c.name;
  return "";
};

const deriveStatus = (raw: RawNewsItem): FilterStatus => {
  const key = raw.status_text ?? (raw.status !== null && raw.status !== undefined ? String(raw.status) : null);
  if (key && STATUS_MAP[key]) return STATUS_MAP[key];
  const sd = raw.schudle_date ?? raw.schedule_date;
  if (sd) {
    const d = new Date(sd);
    if (!Number.isNaN(d.getTime()) && d.getTime() > Date.now()) return "scheduled";
  }
  return toBool(raw.is_published) ? "published" : "draft";
};

const mapRawToNewsItem = (raw: RawNewsItem): NewsItem => {
  const t = indexTranslations(raw.translations);
  const ar = pickLocale(t, "ar");
  const en = pickLocale(t, "en");

  const status = deriveStatus(raw);
  const scheduleDate = raw.schudle_date ?? raw.schedule_date ?? null;
  const scheduleTime = raw.schudle_time ?? raw.schedule_time ?? null;

  const titleAr = ar.title ?? (typeof raw.title === "object" ? raw.title?.ar : undefined) ?? "";
  const titleEn = en.title ?? (typeof raw.title === "object" ? raw.title?.en : undefined) ?? "";

  const tagIds = (raw.tags ?? []).map((tag) => String(tag.id ?? "")).filter(Boolean);

  const catRaw = raw.category as { id?: number | string } | undefined;

  return {
    id: String(raw.id ?? ""),
    title: { ar: titleAr, en: titleEn },
    image: raw.main_image ?? "",
    imageAlt: { ar: ar.title ?? titleAr, en: en.title ?? titleEn },
    category: {
      ar: pickCategoryName(raw.category, "ar"),
      en: pickCategoryName(raw.category, "en"),
    },
    status,
    featured: toBool(raw.is_featured),
    publishDate: scheduleDate,
    publishTime: scheduleTime,
    views: Number(raw.views ?? raw.views_count ?? 0),
    // Form fields
    titleAr,
    titleEn,
    subTitleAr: ar.sub_title ?? "",
    subTitleEn: en.sub_title ?? "",
    descriptionAr: ar.description ?? "",
    descriptionEn: en.description ?? "",
    metaTitleAr: ar.meta_title ?? "",
    metaTitleEn: en.meta_title ?? "",
    metaDescriptionAr: ar.meta_description ?? "",
    metaDescriptionEn: en.meta_description ?? "",
    source: raw.source ?? "",
    slug: raw.slug ?? "",
    direction: raw.direction ?? "rtl",
    isFeatured: toBool(raw.is_featured),
    categoryId: String(raw.category_id ?? catRaw?.id ?? ""),
    subCategoryId: String(raw.sub_category_id ?? ""),
    tags: tagIds,
    mainImageUrl: raw.main_image ?? null,
    metaImageUrl: raw.meta_image ?? null,
    scheduleDate,
    scheduleTime,
  };
};

const extractItems = (payload: RawListResponse | RawNewsItem[] | undefined): RawNewsItem[] => {
  if (!payload) return [];
  if (Array.isArray(payload)) return payload;
  const inner = payload.data;
  if (Array.isArray(inner)) return inner;
  if (inner && typeof inner === "object" && "list" in inner) return inner.list ?? [];
  return payload.list ?? [];
};

const extractTotal = (
  payload: RawListResponse | RawNewsItem[] | undefined,
  fallback: number
): number => {
  if (!payload || Array.isArray(payload)) return fallback;
  return payload.paginator?.total_count ?? payload.meta?.total ?? payload.total ?? fallback;
};

const NETWORK_CODES = new Set(["ERR_NETWORK", "ECONNREFUSED", "ECONNABORTED", "ENOTFOUND", "ETIMEDOUT"]);

const shouldFallback = (status: number, err?: unknown): boolean => {
  if (status === 404 || status === 403) return true;
  if (err instanceof AxiosError) {
    if (!err.response) return true;
    if (err.code && NETWORK_CODES.has(err.code)) return true;
  }
  return false;
};

const emptyListPayload = (page: number, perPage: number): RawListResponse => ({
  list: [],
  paginator: { total_count: 0, total_pages: 0, current_page: page, per_page: perPage },
});

interface FetchResult<T> {
  payload: T;
  isFallback: boolean;
}

// ─── useNewsList ──────────────────────────────────────────────────────────────

interface UseNewsListParams {
  page?: number;
  perPage?: number;
  search?: string;
  status?: string[];
  categoryId?: string;
}

interface UseNewsListReturn {
  data: NewsItem[];
  totalPages: number;
  totalItems: number;
  currentPage: number;
  isLoading: boolean;
  isError: boolean;
  isFallback: boolean;
  refetch: () => void;
}

export function useNewsList({
  page = 1,
  perPage = 8,
  search,
  status,
  categoryId,
}: UseNewsListParams = {}): UseNewsListReturn {
  const queryParams = useMemo(() => {
    const p: Record<string, string | number> = { page, per_page: perPage };
    if (search) p.search = search;
    if (status?.length) p.status = status.join(",");
    if (categoryId) p.category_id = categoryId;
    return p;
  }, [page, perPage, search, status, categoryId]);

  const swrKey = ["/news", queryParams] as const;

  const { data, error, isLoading, mutate } = useSWR<FetchResult<RawListResponse | RawNewsItem[]>>(
    swrKey,
    async ([url, params]) => {
      try {
        const response = await authApi.get<RawListResponse>(url, {
          params,
          validateStatus: () => true,
        });
        if (shouldFallback(response.status)) {
          return { payload: emptyListPayload(page, perPage), isFallback: true };
        }
        if (response.status < 200 || response.status >= 300) {
          throw new AxiosError(`Request failed with status ${response.status}`, String(response.status));
        }
        return { payload: response.data, isFallback: false };
      } catch (err) {
        if (shouldFallback(0, err)) {
          return { payload: emptyListPayload(page, perPage), isFallback: true };
        }
        throw err;
      }
    },
    { keepPreviousData: true, revalidateOnFocus: false, revalidateOnReconnect: false }
  );

  const { items, totalItems } = useMemo(() => {
    const raw = extractItems(data?.payload);
    return { items: raw.map(mapRawToNewsItem), totalItems: extractTotal(data?.payload, raw.length) };
  }, [data?.payload]);

  return {
    data: items,
    totalPages: totalItems > 0 ? Math.ceil(totalItems / perPage) : 0,
    totalItems,
    currentPage: page,
    isLoading,
    isError: Boolean(error),
    isFallback: data?.isFallback ?? false,
    refetch: () => { mutate(); },
  };
}

// ─── useNewsDetail ────────────────────────────────────────────────────────────

interface UseNewsDetailReturn {
  data: NewsItem | null;
  isLoading: boolean;
  isError: boolean;
  isFallback: boolean;
  refetch: () => void;
}

export function useNewsDetail(id: string | number | undefined): UseNewsDetailReturn {
  const enabled = Boolean(id);
  const swrKey = enabled ? (["/news", String(id)] as const) : null;

  const { data, error, isLoading, mutate } = useSWR<FetchResult<{ data?: RawNewsItem } | RawNewsItem>>(
    swrKey,
    async ([url, recordId]) => {
      try {
        const response = await authApi.get<{ data?: RawNewsItem } | RawNewsItem>(
          `${url}/${recordId}`,
          { validateStatus: () => true }
        );
        if (shouldFallback(response.status)) {
          return { payload: {} as RawNewsItem, isFallback: true };
        }
        if (response.status < 200 || response.status >= 300) {
          throw new AxiosError(`Request failed with status ${response.status}`, String(response.status));
        }
        return { payload: response.data, isFallback: false };
      } catch (err) {
        if (shouldFallback(0, err)) {
          return { payload: {} as RawNewsItem, isFallback: true };
        }
        throw err;
      }
    },
    { revalidateOnFocus: false, revalidateOnReconnect: false }
  );

  const item = useMemo(() => {
    if (!data?.payload) return null;
    if (data.isFallback) return null;
    const raw =
      "data" in data.payload && data.payload.data
        ? data.payload.data
        : (data.payload as RawNewsItem);
    if (!raw.id) return null;
    return mapRawToNewsItem(raw);
  }, [data]);

  return {
    data: item,
    isLoading,
    isError: Boolean(error),
    isFallback: data?.isFallback ?? false,
    refetch: () => { mutate(); },
  };
}

// ─── useNewsMutations ─────────────────────────────────────────────────────────

interface UseNewsMutationsReturn {
  create: (fd: FormData) => Promise<void>;
  update: (id: string | number, fd: FormData) => Promise<void>;
  remove: (id: string | number) => Promise<void>;
  isMutating: boolean;
}

export function useNewsMutations(): UseNewsMutationsReturn {
  const [isMutating, setIsMutating] = useState(false);
  const { mutate } = useSWRConfig();

  const invalidateList = useCallback(() => {
    mutate(
      (key) => Array.isArray(key) && key[0] === "/news",
      undefined,
      { revalidate: true }
    );
  }, [mutate]);

  const run = useCallback(
    async (fn: () => Promise<unknown>) => {
      setIsMutating(true);
      try {
        await fn();
        invalidateList();
      } catch (err) {
        if (err instanceof AxiosError) {
          const s = err.response?.status ?? 0;
          if (s === 404 || s === 403 || !err.response) {
            invalidateList();
            return;
          }
        }
        throw err;
      } finally {
        setIsMutating(false);
      }
    },
    [invalidateList]
  );

  return {
    create: (fd) => run(() => authApi.post("/news", fd)),
    update: (id, fd) => run(() => authApi.post(`/news/${id}`, fd)),
    remove: (id) =>
      run(async () => {
        await authApi.delete(`/news/${id}`);
        await mutate(
          (key) => Array.isArray(key) && key[0] === "/news" && key[1] === String(id),
          undefined,
          { revalidate: false }
        );
      }),
    isMutating,
  };
}
```

- [ ] **Step 2: Verify TypeScript compiles**

```bash
npm run build 2>&1 | grep "use-news-data" | head -10
```

Expected: no output (no errors for this file).

- [ ] **Step 3: Commit**

```bash
git add hooks/use-news-data.ts
git commit -m "feat(blogs): add useNewsList, useNewsDetail, useNewsMutations hooks"
```

---

## Task 3: Update `blogs-types.ts` and `blogs-table.tsx`

**Files:**
- Modify: `components/dashboard/blogs/blogs-types.ts`
- Modify: `components/dashboard/blogs/blogs-table.tsx`

- [ ] **Step 1: Trim `blogs-types.ts` — remove `DashboardBlogItem`**

Replace the entire content of `components/dashboard/blogs/blogs-types.ts` with:

```ts
import type { FilterStatus } from "@/components/ui/filter/filter-button";

export type DashboardBlogStatus = FilterStatus;

export interface LocalizedText {
  ar: string;
  en: string;
}
```

- [ ] **Step 2: Update `blogs-table.tsx`**

Replace the entire content of `components/dashboard/blogs/blogs-table.tsx` with:

```tsx
"use client";

import type { NewsItem } from "@/hooks/use-news-data";
import {
  Column,
  DataTable,
  DateTimeCell,
  RowActionsMenu,
} from "@/components/ui/data-table";
import { StatusBadge } from "@/components/ui/status-badge";
import { Switch } from "@/components/ui/switch";
import { useLocale, useTranslations } from "next-intl";
import Image from "next/image";

interface BlogsTableProps {
  data: NewsItem[];
  page: number;
  perPage: number;
  onToggleFeatured?: (id: string, value: boolean) => void;
  onDelete?: (id: string) => void;
  className?: string;
}

export function BlogsTable({
  data,
  page,
  perPage,
  onToggleFeatured,
  onDelete,
  className,
}: BlogsTableProps) {
  const t = useTranslations("dashboard.blogs.table");
  const locale = useLocale();

  const columns: Column<NewsItem>[] = [
    {
      key: "title",
      header: t("headers.title"),
      width: 360,
      align: "start",
      render: (item) => (
        <div className="flex items-center gap-3">
          {item.image ? (
            <Image
              src={item.image}
              alt={locale === "ar" ? item.imageAlt.ar : item.imageAlt.en}
              width={48}
              height={44}
              className="h-11 w-12 rounded-md object-cover"
            />
          ) : (
            <div className="h-11 w-12 rounded-md bg-muted" />
          )}
          <div className="max-w-[260px]">
            <p className="line-clamp-2 text-sm font-medium text-foreground">
              {locale === "ar" ? item.title.ar : item.title.en}
            </p>
          </div>
        </div>
      ),
    },
    {
      key: "category",
      header: t("headers.category"),
      width: 170,
      render: (item) => <>{locale === "ar" ? item.category.ar : item.category.en}</>,
    },
    {
      key: "status",
      header: t("headers.status"),
      width: 150,
      render: (item) => (
        <StatusBadge status={item.status} className="h-7 min-w-[72px]" />
      ),
    },
    {
      key: "publishDate",
      header: t("headers.publishDate"),
      width: 170,
      render: (item) => (
        <DateTimeCell
          date={item.publishDate ?? undefined}
          time={item.publishTime ?? undefined}
          fallback={t("unpublished")}
        />
      ),
    },
    {
      key: "featured",
      header: t("headers.featured"),
      width: 110,
      render: (item) => (
        <div className="flex justify-center">
          <Switch
            checked={item.featured}
            onCheckedChange={(checked) => onToggleFeatured?.(item.id, checked)}
          />
        </div>
      ),
    },
    {
      key: "settings",
      header: t("headers.settings"),
      width: 100,
      render: (item) => (
        <RowActionsMenu
          items={[
            { label: t("actions.preview"), href: `/dashboard/blogs/${item.id}` },
            { label: t("actions.edit"), href: `/dashboard/blogs/${item.id}/edit` },
            {
              label: t("actions.delete"),
              variant: "destructive",
              onClick: () => onDelete?.(item.id),
            },
          ]}
        />
      ),
    },
  ];

  return (
    <DataTable
      data={data}
      columns={columns}
      page={page}
      perPage={perPage}
      minWidth={1020}
      className={className}
    />
  );
}
```

- [ ] **Step 3: Verify build**

```bash
npm run build 2>&1 | grep -E "blogs-table|blogs-types" | head -10
```

Expected: no errors.

- [ ] **Step 4: Commit**

```bash
git add components/dashboard/blogs/blogs-types.ts components/dashboard/blogs/blogs-table.tsx
git commit -m "feat(blogs): update table to use NewsItem type, add onDelete prop"
```

---

## Task 4: Update `blogs-page-view.tsx`

**Files:**
- Modify: `components/dashboard/blogs/blogs-page-view.tsx`

- [ ] **Step 1: Update props to use `NewsItem[]` and thread `onDelete`**

Replace the entire content of `components/dashboard/blogs/blogs-page-view.tsx` with:

```tsx
"use client";

import type { LocalizedText } from "@/components/dashboard/blogs/blogs-types";
import type { NewsItem } from "@/hooks/use-news-data";
import { ExportButton } from "@/components/dashboard/lifi/export-button";
import { ActiveFilterBadges } from "@/components/ui/filter/active-filter-badges";
import { FilterButton } from "@/components/ui/filter/filter-button";
import { Pagination } from "@/components/ui/pagination";
import { SearchInput } from "@/components/ui/search-input";
import { BlogsFilterPanel } from "./blogs-filter-panel";
import { BlogsTable } from "./blogs-table";

interface BlogsPageViewProps {
  data: NewsItem[];
  page: number;
  perPage: number;
  totalPages: number;
  totalItems: number;
  categories: LocalizedText[];
  filtersKey: string;
  isFilterOpen: boolean;
  onToggleFilter: () => void;
  onCloseFilter: () => void;
  onToggleFeatured?: (id: string, value: boolean) => void;
  onDelete?: (id: string) => void;
}

export function BlogsPageView({
  data,
  page,
  perPage,
  totalPages,
  totalItems,
  categories,
  filtersKey,
  isFilterOpen,
  onToggleFilter,
  onCloseFilter,
  onToggleFeatured,
  onDelete,
}: BlogsPageViewProps) {
  return (
    <section className="overflow-hidden rounded-2xl border border-border bg-card">
      <div className="flex flex-wrap items-center justify-between gap-3 p-6">
        <div className="flex flex-wrap items-center gap-3">
          <SearchInput />
          <FilterButton isActive={isFilterOpen} onClick={onToggleFilter} />
        </div>
        <ExportButton />
      </div>

      {isFilterOpen ? (
        <div className="px-6 pb-6">
          <BlogsFilterPanel
            key={filtersKey}
            isOpen={isFilterOpen}
            onClose={onCloseFilter}
            categories={categories}
          />
        </div>
      ) : (
        <ActiveFilterBadges className="px-6 pb-4" />
      )}

      <BlogsTable
        data={data}
        page={page}
        perPage={perPage}
        onToggleFeatured={onToggleFeatured}
        onDelete={onDelete}
      />

      <Pagination
        currentPage={page}
        totalPages={totalPages}
        totalItems={totalItems}
        itemsPerPage={perPage}
        className="border-t border-border"
      />
    </section>
  );
}
```

- [ ] **Step 2: Verify build**

```bash
npm run build 2>&1 | grep "blogs-page-view" | head -10
```

Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add components/dashboard/blogs/blogs-page-view.tsx
git commit -m "feat(blogs): update page view to thread NewsItem type and delete callback"
```

---

## Task 5: Update `blogs-page-container.tsx`

**Files:**
- Modify: `components/dashboard/blogs/blogs-page-container.tsx`

- [ ] **Step 1: Replace mock hook, wire delete Dialog and toggle featured**

Replace the entire content of `components/dashboard/blogs/blogs-page-container.tsx` with:

```tsx
"use client";

import type { DashboardBlogStatus } from "@/components/dashboard/blogs/blogs-types";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { useNewsList, useNewsMutations } from "@/hooks/use-news-data";
import { useToggleFeatured } from "@/hooks/use-featured-news-data";
import { Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { useMemo, useState } from "react";
import { BlogsPageView } from "./blogs-page-view";

const STATUS_VALUES: DashboardBlogStatus[] = ["published", "disabled", "scheduled", "draft"];

function isBlogStatus(value: string): value is DashboardBlogStatus {
  return STATUS_VALUES.includes(value as DashboardBlogStatus);
}

export function BlogsPageContainer() {
  const searchParams = useSearchParams();
  const t = useTranslations("dashboard.blogs.table");
  const [isFilterOpen, setIsFilterOpen] = useState(false);
  const [deletingId, setDeletingId] = useState<string | null>(null);
  const filtersKey = searchParams.toString();

  const params = useMemo(() => {
    const urlSearch = new URLSearchParams(filtersKey);
    const page = Number(urlSearch.get("page") || "1");
    const perPage = Number(urlSearch.get("perPage") || "8");
    const statusParam = (urlSearch.get("status") || "").trim();
    const categoryParam = (urlSearch.get("category") || "").trim();
    const parsedStatus = statusParam
      .split(",")
      .map((v) => v.trim())
      .filter(isBlogStatus);

    return {
      page: Number.isFinite(page) && page > 0 ? page : 1,
      perPage: Number.isFinite(perPage) && perPage > 0 ? perPage : 8,
      search: urlSearch.get("search") || "",
      status: parsedStatus.length ? parsedStatus : undefined,
      categoryId: categoryParam || undefined,
    };
  }, [filtersKey]);

  const { data, totalPages, totalItems } = useNewsList(params);
  const { remove, isMutating: isDeleting } = useNewsMutations();
  const { toggle } = useToggleFeatured();

  const handleConfirmDelete = async () => {
    if (!deletingId) return;
    try {
      await remove(deletingId);
    } finally {
      setDeletingId(null);
    }
  };

  return (
    <>
      <BlogsPageView
        data={data}
        page={params.page}
        perPage={params.perPage}
        totalPages={totalPages}
        totalItems={totalItems}
        categories={[]}
        filtersKey={filtersKey}
        isFilterOpen={isFilterOpen}
        onToggleFilter={() => setIsFilterOpen((prev) => !prev)}
        onCloseFilter={() => setIsFilterOpen(false)}
        onToggleFeatured={(id) => toggle(id)}
        onDelete={(id) => setDeletingId(id)}
      />

      <Dialog
        open={deletingId !== null}
        onOpenChange={(open) => { if (!open) setDeletingId(null); }}
      >
        <DialogContent className="max-w-[420px] rounded-2xl p-6 text-center">
          <DialogHeader className="items-center gap-0 text-center">
            <div className="mx-auto flex size-12 items-center justify-center rounded-full bg-destructive/10">
              <Trash2 className="size-6 text-destructive" />
            </div>
            <DialogTitle className="mt-4 text-base font-bold text-foreground">
              {t("deleteDialog.title")}
            </DialogTitle>
            <DialogDescription className="mt-2 text-sm text-muted-foreground">
              {t("deleteDialog.description")}
            </DialogDescription>
          </DialogHeader>
          <div className="mt-6 grid grid-cols-2 gap-3">
            <DialogClose asChild>
              <Button
                type="button"
                variant="outline"
                disabled={isDeleting}
                className="h-11 rounded-xl"
              >
                {t("deleteDialog.cancel")}
              </Button>
            </DialogClose>
            <Button
              type="button"
              disabled={isDeleting}
              onClick={handleConfirmDelete}
              className="h-11 rounded-xl bg-destructive font-bold text-white hover:bg-destructive/90"
            >
              {t("deleteDialog.confirm")}
            </Button>
          </div>
        </DialogContent>
      </Dialog>
    </>
  );
}
```

- [ ] **Step 2: Add missing translation keys**

Open `messages/ar.json`. Under `dashboard.blogs.table`, add:

```json
"deleteDialog": {
  "title": "حذف المقال",
  "description": "هل أنت متأكد من حذف هذا المقال؟ لا يمكن التراجع عن هذا الإجراء.",
  "cancel": "إلغاء",
  "confirm": "حذف"
}
```

Open `messages/en.json`. Under `dashboard.blogs.table`, add:

```json
"deleteDialog": {
  "title": "Delete Article",
  "description": "Are you sure you want to delete this article? This action cannot be undone.",
  "cancel": "Cancel",
  "confirm": "Delete"
}
```

- [ ] **Step 3: Verify build**

```bash
npm run build 2>&1 | grep "blogs-page-container" | head -10
```

Expected: no errors.

- [ ] **Step 4: Commit**

```bash
git add components/dashboard/blogs/blogs-page-container.tsx messages/ar.json messages/en.json
git commit -m "feat(blogs): wire news list, delete, and toggle-featured to real API"
```

---

## Task 6: Update `blog-upsert-form.tsx`

**Files:**
- Modify: `components/dashboard/blogs/blog-upsert-form.tsx`
- Modify: `app/[locale]/(dashboard)/dashboard/blogs/[id]/edit/page.tsx`

- [ ] **Step 1: Rewrite `blog-upsert-form.tsx`**

Replace the entire content of `components/dashboard/blogs/blog-upsert-form.tsx` with:

```tsx
"use client";

import { BlogUpsertFormView } from "@/components/dashboard/blogs/blog-upsert-form-view";
import { useNewsDetail } from "@/hooks/use-news-data";
import { useRouter } from "@/i18n/routing";
import { authApi } from "@/swr/auth-config";
import { toast } from "react-toastify";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import useSWR from "swr";

export type BlogDirection = "rtl" | "ltr";
export type BlogPublishMode = "instant" | "schedule";

export interface BlogUpsertFormValues {
  titleAr: string;
  titleEn: string;
  subTitleAr: string;
  subTitleEn: string;
  descriptionAr: string;
  descriptionEn: string;
  metaTitleAr: string;
  metaTitleEn: string;
  metaDescriptionAr: string;
  metaDescriptionEn: string;
  source: string;
  slug: string;
  imageDescription: string;
  direction: BlogDirection;
  isFeatured: boolean;
  publishMode: BlogPublishMode;
  scheduleDate: string;
  scheduleTime: string;
  categoryId: string;
  subCategoryId: string;
  tags: string[];
  mainImage: File | string | null;
  metaImage: File | string | null;
}

export const MAX_TITLE_LENGTH = 120;
export const MAX_SUB_TITLE_LENGTH = 75;
export const MAX_DESCRIPTION_LENGTH = 325;

export interface CategoryOption {
  id: string;
  label: { ar?: string; en?: string };
  subCategories: { id: string; label: { ar?: string; en?: string } }[];
}

export interface TagOption {
  id: string;
  label: { ar?: string; en?: string };
}

type RawCategory = {
  id: number | string;
  translations?: { ar?: { name?: string }; en?: { name?: string } } | Array<{ locale?: string; name?: string }>;
  name?: { ar?: string; en?: string } | string;
  sub_categories?: RawCategory[];
  children?: RawCategory[];
};

type RawTag = {
  id: number | string;
  translations?: { ar?: { name?: string }; en?: { name?: string } } | Array<{ locale?: string; name?: string }>;
  name?: { ar?: string; en?: string } | string;
};

const normaliseLabel = (source: RawCategory | RawTag): { ar?: string; en?: string } => {
  if (source.translations) {
    if (Array.isArray(source.translations)) {
      const ar = source.translations.find((t) => t.locale === "ar")?.name;
      const en = source.translations.find((t) => t.locale === "en")?.name;
      return { ar, en };
    }
    const t = source.translations as { ar?: { name?: string }; en?: { name?: string } };
    return { ar: t.ar?.name, en: t.en?.name };
  }
  if (typeof source.name === "object" && source.name !== null) return source.name;
  if (typeof source.name === "string") return { ar: source.name, en: source.name };
  return {};
};

const normaliseCategories = (raw: RawCategory[] | undefined): CategoryOption[] => {
  if (!Array.isArray(raw)) return [];
  return raw.map((item) => {
    const subs = item.sub_categories ?? item.children ?? [];
    return {
      id: String(item.id),
      label: normaliseLabel(item),
      subCategories: subs.map((sub) => ({ id: String(sub.id), label: normaliseLabel(sub) })),
    };
  });
};

const normaliseTags = (raw: RawTag[] | undefined): TagOption[] => {
  if (!Array.isArray(raw)) return [];
  return raw.map((item) => ({ id: String(item.id), label: normaliseLabel(item) }));
};

const DEFAULT_VALUES: BlogUpsertFormValues = {
  titleAr: "",
  titleEn: "",
  subTitleAr: "",
  subTitleEn: "",
  descriptionAr: "",
  descriptionEn: "",
  metaTitleAr: "",
  metaTitleEn: "",
  metaDescriptionAr: "",
  metaDescriptionEn: "",
  source: "",
  slug: "",
  imageDescription: "",
  direction: "rtl",
  isFeatured: false,
  publishMode: "instant",
  scheduleDate: "",
  scheduleTime: "",
  categoryId: "",
  subCategoryId: "",
  tags: [],
  mainImage: null,
  metaImage: null,
};

const buildBlogFormData = (
  values: BlogUpsertFormValues,
  options: { method?: "put"; asDraft?: boolean } = {}
): FormData => {
  const fd = new FormData();
  const { asDraft = false } = options;

  fd.append("source", values.source);
  fd.append("is_featured", values.isFeatured ? "1" : "0");
  fd.append("image_description", values.imageDescription);
  fd.append("direction", values.direction);
  if (values.slug) fd.append("slug", values.slug);

  const isInstant = !asDraft && values.publishMode === "instant";
  const isSchedule = !asDraft && values.publishMode === "schedule";
  fd.append("is_published", isInstant ? "1" : "0");
  if (isSchedule) {
    fd.append("schudle_date", values.scheduleDate);
    fd.append("schudle_time", values.scheduleTime);
  }

  if (values.categoryId) fd.append("category_id", values.categoryId);
  if (values.subCategoryId) fd.append("sub_category_id", values.subCategoryId);
  values.tags.forEach((tagId) => fd.append("tags[]", tagId));

  fd.append("translations[ar][title]", values.titleAr);
  fd.append("translations[en][title]", values.titleEn);
  fd.append("translations[ar][sub_title]", values.subTitleAr);
  fd.append("translations[en][sub_title]", values.subTitleEn);
  fd.append("translations[ar][description]", values.descriptionAr);
  fd.append("translations[en][description]", values.descriptionEn);
  fd.append("translations[ar][meta_title]", values.metaTitleAr);
  fd.append("translations[en][meta_title]", values.metaTitleEn);
  fd.append("translations[ar][meta_description]", values.metaDescriptionAr);
  fd.append("translations[en][meta_description]", values.metaDescriptionEn);

  // Only send File instances — URL strings mean "keep existing"
  if (values.mainImage instanceof File) fd.append("main_image", values.mainImage);
  if (values.metaImage instanceof File) fd.append("meta_image", values.metaImage);

  if (options.method === "put") fd.append("_method", "put");

  return fd;
};

// Raw list shape returned by /category and /tag endpoints
interface RawListWrapper {
  list?: RawCategory[] | RawTag[];
  data?: RawCategory[] | RawTag[] | { list?: RawCategory[] | RawTag[] };
}

const extractList = <T,>(payload: { data?: unknown; list?: unknown } | T[] | undefined): T[] => {
  if (!payload) return [];
  if (Array.isArray(payload)) return payload as T[];
  const p = payload as { data?: unknown; list?: unknown };
  const inner = p.data;
  if (Array.isArray(inner)) return inner as T[];
  if (inner && typeof inner === "object" && "list" in inner) return (inner as { list?: T[] }).list ?? [];
  if (Array.isArray(p.list)) return p.list as T[];
  return [];
};

interface BlogUpsertFormProps {
  mode: "create" | "edit";
  blogId?: string | number;
  initialValues?: Partial<BlogUpsertFormValues>;
  onSuccess?: () => void;
}

export function BlogUpsertForm({
  mode,
  blogId,
  initialValues,
  onSuccess,
}: BlogUpsertFormProps) {
  const router = useRouter();
  const t = useTranslations("dashboard.blogs.form");
  const [isSubmitting, setIsSubmitting] = useState(false);

  const form = useForm<BlogUpsertFormValues>({
    defaultValues: { ...DEFAULT_VALUES, ...initialValues },
    mode: "onBlur",
  });

  // Edit hydration
  const { data: detail } = useNewsDetail(mode === "edit" ? blogId : undefined);

  useEffect(() => {
    if (!detail) return;
    form.reset({
      titleAr: detail.titleAr,
      titleEn: detail.titleEn,
      subTitleAr: detail.subTitleAr,
      subTitleEn: detail.subTitleEn,
      descriptionAr: detail.descriptionAr,
      descriptionEn: detail.descriptionEn,
      metaTitleAr: detail.metaTitleAr,
      metaTitleEn: detail.metaTitleEn,
      metaDescriptionAr: detail.metaDescriptionAr,
      metaDescriptionEn: detail.metaDescriptionEn,
      source: detail.source,
      slug: detail.slug,
      imageDescription: "",
      direction: detail.direction,
      isFeatured: detail.isFeatured,
      publishMode: detail.scheduleDate ? "schedule" : "instant",
      scheduleDate: detail.scheduleDate ?? "",
      scheduleTime: detail.scheduleTime ?? "",
      categoryId: detail.categoryId,
      subCategoryId: detail.subCategoryId,
      tags: detail.tags,
      mainImage: detail.mainImageUrl ?? null,
      metaImage: detail.metaImageUrl ?? null,
    });
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [detail?.id]);

  // Category + tag options (authApi — same SWR cache as useTaxonomyList)
  const { data: rawCategories } = useSWR<RawListWrapper>(
    ["/category", { per_page: 100 }],
    ([url, params]: [string, Record<string, number>]) =>
      authApi.get(url, { params }).then((r) => r.data),
    { revalidateOnFocus: false }
  );
  const { data: rawTags } = useSWR<RawListWrapper>(
    ["/tag", { per_page: 100 }],
    ([url, params]: [string, Record<string, number>]) =>
      authApi.get(url, { params }).then((r) => r.data),
    { revalidateOnFocus: false }
  );

  const categories = normaliseCategories(extractList<RawCategory>(rawCategories));
  const tags = normaliseTags(extractList<RawTag>(rawTags));

  const submit = async (values: BlogUpsertFormValues, asDraft = false) => {
    const isUpdate = mode === "edit";
    const formData = buildBlogFormData(values, {
      method: isUpdate ? "put" : undefined,
      asDraft,
    });

    setIsSubmitting(true);
    try {
      const url = isUpdate && blogId ? `/news/${blogId}` : "/news";
      await authApi.post(url, formData);
      toast.success(isUpdate ? t("updateSuccess") : t("createSuccess"));
      onSuccess?.();
      if (!onSuccess) router.push("/dashboard/blogs");
    } catch {
      // authApi interceptor toasts on error
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <BlogUpsertFormView
      form={form}
      maxTitleLength={MAX_TITLE_LENGTH}
      maxSubTitleLength={MAX_SUB_TITLE_LENGTH}
      maxDescriptionLength={MAX_DESCRIPTION_LENGTH}
      categories={categories}
      tags={tags}
      isSubmitting={isSubmitting}
      mode={mode}
      onSubmit={(values) => submit(values, false)}
      onSaveDraft={() => submit(form.getValues(), true)}
    />
  );
}

export default BlogUpsertForm;
```

- [ ] **Step 2: Update edit page to remove mock data call**

Replace the entire content of `app/[locale]/(dashboard)/dashboard/blogs/[id]/edit/page.tsx` with:

```tsx
import { DashboardBreadcrumbConfig } from "@/components/dashboard/breadcrumb-config";
import { BlogUpsertForm } from "@/components/dashboard/blogs/blog-upsert-form";
import { getTranslations } from "next-intl/server";

interface EditBlogPageProps {
  params: Promise<{ id: string }>;
}

export default async function EditBlogPage({ params }: EditBlogPageProps) {
  const { id } = await params;
  const tBreadcrumb = await getTranslations("dashboard.breadcrumb");
  const tBlogs = await getTranslations("dashboard.blogs");

  return (
    <>
      <DashboardBreadcrumbConfig
        title={tBlogs("titles.edit", { id })}
        breadcrumbs={[
          { title: tBreadcrumb("home"), href: "/dashboard" },
          { title: tBreadcrumb("blog"), href: "/dashboard/blogs" },
          { title: tBlogs("titles.list"), href: "/dashboard/blogs" },
          { title: tBlogs("titles.edit", { id }), href: `/dashboard/blogs/${id}/edit` },
        ]}
        action={{
          label: tBreadcrumb("previewInWebsite"),
          href: `/dashboard/blogs/${id}`,
          icon: "eye",
          style: "outline",
        }}
      />

      <BlogUpsertForm mode="edit" blogId={id} />
    </>
  );
}
```

- [ ] **Step 3: Verify build**

```bash
npm run build 2>&1 | grep -E "blog-upsert-form|edit/page" | head -10
```

Expected: no errors.

- [ ] **Step 4: Commit**

```bash
git add components/dashboard/blogs/blog-upsert-form.tsx "app/[locale]/(dashboard)/dashboard/blogs/[id]/edit/page.tsx"
git commit -m "feat(blogs): switch upsert form to authApi, add edit hydration from GET /news/{id}"
```

---

## Task 7: Swap Lexical → TipTap in `blog-upsert-form-view.tsx`

**Files:**
- Modify: `components/dashboard/blogs/blog-upsert-form-view.tsx`

- [ ] **Step 1: Replace the Lexical import with TipTap**

In `components/dashboard/blogs/blog-upsert-form-view.tsx`:

**Remove** this import:
```tsx
import { LexicalRichTextEditor } from "@/components/lexical/lexical-rich-text-editor";
```

**Add** this import in its place:
```tsx
import { RichTextEditor } from "@/components/tiptap/rich-text-editor";
```

- [ ] **Step 2: Replace the Arabic editor instance**

Find this block (around line 358):
```tsx
<LexicalRichTextEditor
  value={field.value}
  onChange={field.onChange}
  dir="rtl"
  placeholder={t("descriptionPlaceholderAr")}
  namespace="BlogEditorAr"
/>
```

Replace with:
```tsx
<RichTextEditor
  value={field.value}
  onChange={field.onChange}
  dir="rtl"
/>
```

- [ ] **Step 3: Replace the English editor instance**

Find this block (around line 386):
```tsx
<LexicalRichTextEditor
  value={field.value}
  onChange={field.onChange}
  dir="ltr"
  placeholder={t("descriptionPlaceholderEn")}
  namespace="BlogEditorEn"
/>
```

Replace with:
```tsx
<RichTextEditor
  value={field.value}
  onChange={field.onChange}
  dir="ltr"
/>
```

- [ ] **Step 4: Verify build**

```bash
npm run build 2>&1 | grep "blog-upsert-form-view" | head -10
```

Expected: no errors.

- [ ] **Step 5: Full build and lint check**

```bash
npm run lint 2>&1 | tail -20
npm run build 2>&1 | tail -10
```

Expected: both pass with no errors.

- [ ] **Step 6: Commit**

```bash
git add components/dashboard/blogs/blog-upsert-form-view.tsx
git commit -m "feat(blogs): replace Lexical editor with TipTap RichTextEditor in upsert form"
```

---

> **Note:** `RowActionsMenu` in `components/ui/data-table/row-actions-menu.tsx` already defines `onClick?: () => void` on its `RowAction` type and wires it to `DropdownMenuItem`. No changes needed.
