# Recherche Pagination + Enrichment Export — 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:** Paginate the Recherche, keywords Réservoir, and Backlink (réservoir + profil) tables (shadcn pagination controls, selectable page size, top + bottom) and add a Recherche "Exporter" action that emits tool-ready keyword lists (chunked to Mangools/Semrush/Ahrefs bulk limits) for the keywords still missing SEO metrics.

**Architecture:** Pure chunking util (`export-enrichment.ts`, fully unit-tested) feeds a client-side download dialog (`.txt` for one chunk, `.zip` via `jszip` for many). Pagination extends the existing shared `DataTable` (which already gates `getPaginationRowModel` on a `pageSize` prop) with a page-size `Select` and a controls bar rendered above and below the table. Everything is client-side — the rows are already loaded.

**Tech Stack:** Next.js 16 (App Router, client components), TanStack Table, shadcn `Select`/`Dialog`/`Button`, `jszip`, bun + biome, bun:test.

Spec: `docs/superpowers/specs/2026-06-09-recherche-pagination-export-design.md`.

**Branch:** create `feature/recherche-pagination-export` from `dev` before Task 1:
```bash
git checkout dev && git checkout -b feature/recherche-pagination-export
```

---

## File Structure

- **Create** `src/lib/keywords/export-enrichment.ts` — pure: `TOOL_LIMITS`, `EnrichmentTool`, `hasNoMetric`, `buildEnrichmentFiles`. No React, no DB.
- **Create** `src/lib/keywords/export-enrichment.test.ts` — bun:test, no DB (pure).
- **Create** `src/components/keywords/export-enrichment-dialog.tsx` — the export dialog (tool choice, recap, download).
- **Modify** `src/components/ui/data-table.tsx` — add `pageSizeOptions` + size `Select` + top/bottom controls bar.
- **Modify** `src/components/keywords/research/research-container.tsx` — "Exporter" button + dialog wiring + pass pagination props to `DataTable`.
- **Dependency:** `jszip` (install via `sfw bun add jszip`).

Keyword shape used (from `src/lib/keywords/types.ts`): `{ id, term: string, locale: { country: string }, primarySource: KeywordSource, metrics: Partial<Record<KeywordSource, SourceMetrics>> }`. `SourceMetrics.volume` is the field that's absent for GSC-only keywords.

---

## Task 1: Enrichment chunking util (pure, TDD)

**Files:**
- Create: `src/lib/keywords/export-enrichment.ts`
- Test: `src/lib/keywords/export-enrichment.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// src/lib/keywords/export-enrichment.test.ts
import { expect, test } from "bun:test";
import type { Keyword } from "./types";
import {
  buildEnrichmentFiles,
  hasNoMetric,
  TOOL_LIMITS,
} from "./export-enrichment";

// Minimal factory — only the fields the util reads matter; cast for the rest.
function kw(
  term: string,
  opts: {
    country?: string;
    primarySource?: Keyword["primarySource"];
    metrics?: Keyword["metrics"];
  } = {},
): Keyword {
  return {
    id: `id-${term}`,
    term,
    locale: { lang: "fr", country: opts.country ?? "FR" },
    primarySource: opts.primarySource ?? "gsc",
    metrics: opts.metrics ?? {},
  } as unknown as Keyword;
}

test("hasNoMetric: true when the primary source has no volume, false otherwise", () => {
  expect(hasNoMetric(kw("a"))).toBe(true); // gsc-only, metrics {}
  expect(
    hasNoMetric(
      kw("b", { primarySource: "semrush", metrics: { semrush: { volume: 100 } as never } }),
    ),
  ).toBe(false);
  // a genuine 0 volume is "present", not missing
  expect(
    hasNoMetric(
      kw("c", { primarySource: "semrush", metrics: { semrush: { volume: 0 } as never } }),
    ),
  ).toBe(false);
});

test("buildEnrichmentFiles: keeps only missing-metric keywords, dedups by lowercased term", () => {
  const files = buildEnrichmentFiles(
    [
      kw("Audit"),
      kw("audit"), // dup (case-insensitive) → dropped
      kw("has data", { primarySource: "semrush", metrics: { semrush: { volume: 50 } as never } }),
      kw("b2b energie"),
    ],
    "mangools",
  );
  expect(files).toHaveLength(1);
  expect(files[0].country).toBe("FR");
  expect(files[0].terms).toEqual(["Audit", "b2b energie"]);
  expect(files[0].index).toBe(1);
  expect(files[0].total).toBe(1);
});

test("buildEnrichmentFiles: chunks each country at the tool limit", () => {
  const many = Array.from({ length: TOOL_LIMITS.mangools + 1 }, (_, i) =>
    kw(`t${i}`),
  );
  const files = buildEnrichmentFiles(many, "mangools");
  expect(files).toHaveLength(2);
  expect(files[0].terms).toHaveLength(700);
  expect(files[0]).toMatchObject({ index: 1, total: 2 });
  expect(files[1].terms).toHaveLength(1);
  expect(files[1]).toMatchObject({ index: 2, total: 2 });
});

test("buildEnrichmentFiles: groups by country", () => {
  const files = buildEnrichmentFiles(
    [kw("fr1", { country: "FR" }), kw("us1", { country: "US" }), kw("fr2", { country: "FR" })],
    "ahrefs",
  );
  const fr = files.find((f) => f.country === "FR");
  const us = files.find((f) => f.country === "US");
  expect(fr?.terms).toEqual(["fr1", "fr2"]);
  expect(us?.terms).toEqual(["us1"]);
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `bun --conditions=react-server test src/lib/keywords/export-enrichment.test.ts`
Expected: FAIL — `Export named 'buildEnrichmentFiles' not found` (module doesn't exist).

- [ ] **Step 3: Write the implementation**

```ts
// src/lib/keywords/export-enrichment.ts — build tool-ready keyword lists from the
// Recherche keywords that still lack SEO metrics, chunked to each tool's bulk limit.
import type { Keyword } from "./types";

export type EnrichmentTool = "mangools" | "semrush" | "ahrefs";

/** Max keywords per bulk import, per tool. */
export const TOOL_LIMITS: Record<EnrichmentTool, number> = {
  mangools: 700,
  semrush: 2000,
  ahrefs: 10000,
};

/** "No metric" = the primary source carries no volume — the same condition the
 *  table renders as N/A. These are the keywords worth re-enriching. */
export function hasNoMetric(k: Keyword): boolean {
  return k.metrics[k.primarySource]?.volume == null;
}

export type EnrichmentFile = {
  country: string;
  /** 1-based position within its country group */
  index: number;
  /** number of files in its country group */
  total: number;
  terms: string[];
};

/** Missing-metric keywords → grouped by country, deduped by lowercased term,
 *  each group sliced into files of at most TOOL_LIMITS[tool] terms. */
export function buildEnrichmentFiles(
  keywords: Keyword[],
  tool: EnrichmentTool,
): EnrichmentFile[] {
  const limit = TOOL_LIMITS[tool];
  const byCountry = new Map<string, Map<string, string>>(); // country -> (lowerTerm -> firstSeenTerm)
  for (const k of keywords) {
    if (!hasNoMetric(k)) continue;
    const term = k.term.trim();
    if (!term) continue;
    const country = k.locale.country;
    let terms = byCountry.get(country);
    if (!terms) {
      terms = new Map();
      byCountry.set(country, terms);
    }
    const key = term.toLowerCase();
    if (!terms.has(key)) terms.set(key, term);
  }

  const files: EnrichmentFile[] = [];
  for (const [country, terms] of byCountry) {
    const list = [...terms.values()];
    const total = Math.ceil(list.length / limit);
    for (let i = 0; i < total; i++) {
      files.push({
        country,
        index: i + 1,
        total,
        terms: list.slice(i * limit, (i + 1) * limit),
      });
    }
  }
  return files;
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `bun --conditions=react-server test src/lib/keywords/export-enrichment.test.ts`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add src/lib/keywords/export-enrichment.ts src/lib/keywords/export-enrichment.test.ts
git commit -m "feat(keywords): enrichment-export chunking util (per-tool, per-country)"
```

---

## Task 2: Dependencies — jszip + shadcn Pagination

**Files:** `package.json`, `bun.lock`, `src/components/ui/pagination.tsx` (new, via shadcn CLI).

- [ ] **Step 1: Install jszip with the Socket firewall**

Run: `sfw bun add jszip`
Expected: `installed jszip@…`, lockfile updated.

- [ ] **Step 2: Verify jszip resolves**

Run: `bun -e "import('jszip').then(m => console.log('JSZIP_OK', typeof m.default))"`
Expected: `JSZIP_OK function`.

- [ ] **Step 3: Add the shadcn Pagination component**

Run: `bunx --bun shadcn@latest add pagination`
Expected: creates `src/components/ui/pagination.tsx` exporting `Pagination`, `PaginationContent`, `PaginationItem`, `PaginationLink`, `PaginationPrevious`, `PaginationNext`, `PaginationEllipsis`.

- [ ] **Step 4: Verify the component file is correct**

Read `src/components/ui/pagination.tsx`. Confirm icon imports are `lucide-react` (the project's icon library — the registry source uses an `IconPlaceholder`; the CLI resolves it to lucide `ChevronLeftIcon`/`ChevronRightIcon`/`MoreHorizontalIcon`). Fix any non-lucide imports if present.

- [ ] **Step 5: Commit**

```bash
git add package.json bun.lock src/components/ui/pagination.tsx
git commit -m "build(deps): add jszip + shadcn pagination component"
```

---

## Task 3: DataTable — page-size selector + top/bottom controls

**Files:**
- Modify: `src/components/ui/data-table.tsx`

The table already gates pagination on the `pageSize` prop and renders a bottom prev/next footer (lines ~46-69 props, ~287-322 render). This task adds a `pageSizeOptions` prop + a page-size `Select`, and renders the controls bar **above and below** the table.

- [ ] **Step 1: Add the Select + Pagination imports**

At the top of `src/components/ui/data-table.tsx`, after the existing `@/components/ui/table` import block, add:

```tsx
import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
```

Remove the now-unused `ChevronLeftIcon` and `ChevronRightIcon` from the existing `lucide-react` import (the shadcn `Pagination` renders its own chevrons). Keep `ArrowDownIcon`, `ArrowUpIcon`, `ChevronsUpDownIcon`.

- [ ] **Step 2: Add the `pageSizeOptions` prop**

In `interface DataTableProps`, right after the `pageSize?: number;` member, add:

```tsx
  /** When set (with `pageSize`), renders a page-size selector in the controls bar. */
  pageSizeOptions?: number[];
```

And add `pageSizeOptions` to the destructured params in the `DataTable(...)` signature (after `pageSize,`).

- [ ] **Step 3: Replace the paginated return block with top+bottom controls**

Replace the entire final block (from `// Default path:` comment through the end of the function, i.e. the `if (!paginated) return tableEl;` line and the `return ( <div className="space-y-2"> … )` that follows) with:

```tsx
  // Default path: render the table exactly as before (no wrapper, no controls).
  if (!paginated) return tableEl;

  const pageState = table.getState().pagination;
  const controls = (
    <div className="flex items-center justify-between gap-2 px-1 text-muted-foreground text-sm">
      <span>
        Page {pageState.pageIndex + 1} / {table.getPageCount() || 1} ·{" "}
        {table.getFilteredRowModel().rows.length} résultats
      </span>
      <div className="flex items-center gap-2">
        {pageSizeOptions ? (
          <Select
            value={String(pageState.pageSize)}
            onValueChange={(v) => table.setPageSize(Number(v))}
          >
            <SelectTrigger size="sm" className="w-[6rem]">
              <SelectValue />
            </SelectTrigger>
            <SelectContent position="popper">
              {pageSizeOptions.map((n) => (
                <SelectItem key={n} value={String(n)}>
                  {n} / page
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        ) : null}
        <Pagination className="mx-0 w-auto">
          <PaginationContent>
            <PaginationItem>
              <PaginationPrevious
                text="Précédent"
                onClick={() => table.previousPage()}
                aria-disabled={!table.getCanPreviousPage()}
                className={cn(
                  !table.getCanPreviousPage() &&
                    "pointer-events-none opacity-40",
                )}
              />
            </PaginationItem>
            <PaginationItem>
              <PaginationNext
                text="Suivant"
                onClick={() => table.nextPage()}
                aria-disabled={!table.getCanNextPage()}
                className={cn(
                  !table.getCanNextPage() && "pointer-events-none opacity-40",
                )}
              />
            </PaginationItem>
          </PaginationContent>
        </Pagination>
      </div>
    </div>
  );

  return (
    <div className="space-y-2">
      {controls}
      {tableEl}
      {controls}
    </div>
  );
```

(`ChevronLeftIcon`/`ChevronRightIcon` are already imported at the top of the file.)

- [ ] **Step 4: Typecheck + lint**

Run: `npx tsc --noEmit` → Expected: `No errors found`.
Run: `bunx biome check --write src/components/ui/data-table.tsx` → Expected: clean.

- [ ] **Step 5: Commit**

```bash
git add src/components/ui/data-table.tsx
git commit -m "feat(ui): DataTable page-size selector + top/bottom pagination controls"
```

---

## Task 4: Export dialog component

**Files:**
- Create: `src/components/keywords/export-enrichment-dialog.tsx`

Mirror the existing dialog style in `src/components/keywords/import-keywords-heavy-dialog.tsx` (shadcn `Dialog`). Use `sonner` `toast` for feedback. The dialog receives the current Recherche rows and the site id.

- [ ] **Step 1: Write the component**

```tsx
// src/components/keywords/export-enrichment-dialog.tsx
"use client";

import JSZip from "jszip";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  buildEnrichmentFiles,
  type EnrichmentTool,
  TOOL_LIMITS,
} from "@/lib/keywords/export-enrichment";
import type { Keyword } from "@/lib/keywords/types";

const TOOL_LABELS: Record<EnrichmentTool, string> = {
  mangools: "Mangools (700)",
  semrush: "Semrush (2000)",
  ahrefs: "Ahrefs (10000)",
};

function triggerDownload(blob: Blob, filename: string) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  URL.revokeObjectURL(url);
}

export function ExportEnrichmentDialog({
  open,
  onOpenChange,
  keywords,
  siteId,
}: {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  keywords: Keyword[];
  siteId: string;
}) {
  const [tool, setTool] = useState<EnrichmentTool>("mangools");
  const files = useMemo(
    () => buildEnrichmentFiles(keywords, tool),
    [keywords, tool],
  );
  const totalTerms = files.reduce((n, f) => n + f.terms.length, 0);

  async function handleExport() {
    if (!files.length) return;
    const name = (f: { country: string; index: number; total: number }) =>
      `recherche-${siteId}-${tool}-${f.country}-${f.index}.txt`;
    try {
      if (files.length === 1) {
        triggerDownload(
          new Blob([files[0].terms.join("\n")], { type: "text/plain" }),
          name(files[0]),
        );
      } else {
        const zip = new JSZip();
        for (const f of files) zip.file(name(f), f.terms.join("\n"));
        const blob = await zip.generateAsync({ type: "blob" });
        triggerDownload(blob, `recherche-${siteId}-${tool}.zip`);
      }
      toast.success(`Export généré — ${totalTerms} mot(s)-clé(s), ${files.length} fichier(s)`);
      onOpenChange(false);
    } catch {
      toast.error("Échec de la génération de l'export");
    }
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Exporter pour enrichissement</DialogTitle>
          <DialogDescription>
            Liste des mots-clés sans métrique, prête à coller dans l'outil choisi
            (1 terme par ligne, découpée à sa limite d'import).
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-3">
          <Select value={tool} onValueChange={(v) => setTool(v as EnrichmentTool)}>
            <SelectTrigger className="w-full">
              <SelectValue />
            </SelectTrigger>
            <SelectContent position="popper">
              {(Object.keys(TOOL_LABELS) as EnrichmentTool[]).map((t) => (
                <SelectItem key={t} value={t}>
                  {TOOL_LABELS[t]}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>

          <p className="text-muted-foreground text-sm">
            {totalTerms === 0
              ? "Aucun mot-clé à enrichir (tous ont déjà des métriques)."
              : `${totalTerms} mot(s)-clé(s) sans métrique · ${files.length} fichier(s) (≤ ${TOOL_LIMITS[tool]} / fichier)`}
          </p>
        </div>

        <div className="flex justify-end gap-2">
          <Button variant="outline" onClick={() => onOpenChange(false)}>
            Annuler
          </Button>
          <Button onClick={handleExport} disabled={totalTerms === 0}>
            Télécharger
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}
```

- [ ] **Step 2: Typecheck + lint**

Run: `npx tsc --noEmit` → Expected: `No errors found`.
Run: `bunx biome check --write src/components/keywords/export-enrichment-dialog.tsx` → Expected: clean.

> If `tsc` flags the `Dialog*` import names, open `src/components/ui/dialog.tsx` and use whatever it actually exports (the shadcn set: `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`). Do NOT invent names.

- [ ] **Step 3: Commit**

```bash
git add src/components/keywords/export-enrichment-dialog.tsx
git commit -m "feat(keywords): enrichment export dialog (txt / zip, per-tool)"
```

---

## Task 5: Wire into Recherche (export button + pagination)

**Files:**
- Modify: `src/components/keywords/research/research-container.tsx`

- [ ] **Step 1: Add imports + dialog state**

At the top of the file, with the other component imports, add:

```tsx
import { ExportEnrichmentDialog } from "../export-enrichment-dialog";
```

Inside the component, with the other `useState` hooks (near `const [openKeyword, setOpenKeyword] = useState…`), add:

```tsx
  const [exportOpen, setExportOpen] = useState(false);
```

- [ ] **Step 2: Add the "Exporter" button to the toolbar**

In the toolbar `<div className="flex items-center gap-2">` (the one containing "Trier par opportunité"), add this button right before the `<Button … onClick={toggleOpportunity}>` opportunity button:

```tsx
          <Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
            Exporter
          </Button>
```

- [ ] **Step 3: Pass pagination props to DataTable**

In the `<DataTable … />` usage, add these two props (e.g. right after `caption=…`):

```tsx
            pageSize={50}
            pageSizeOptions={[20, 50, 100, 500]}
```

- [ ] **Step 4: Render the dialog**

Just before the closing of the component's returned tree (next to `<KeywordSheet … />`), add:

```tsx
      <ExportEnrichmentDialog
        open={exportOpen}
        onOpenChange={setExportOpen}
        keywords={rows}
        siteId={activeSiteId}
      />
```

- [ ] **Step 5: Typecheck + lint**

Run: `npx tsc --noEmit` → Expected: `No errors found`.
Run: `bunx biome check --write src/components/keywords/research/research-container.tsx` → Expected: clean.

- [ ] **Step 6: Commit**

```bash
git add src/components/keywords/research/research-container.tsx
git commit -m "feat(keywords): Recherche pagination + Exporter button"
```

---

## Task 6: Enable pagination on the Réservoir + Backlink tables

All three use the shared `DataTable`, so enabling pagination = passing the two props. The shared component renders the shadcn controls top + bottom automatically.

**Files:**
- Modify: `src/components/keywords/reservoir.tsx`
- Modify: `src/components/backlink/reservoir-container.tsx`
- Modify: `src/components/backlink/domain-profile-container.tsx`

- [ ] **Step 1: keywords Réservoir**

In `src/components/keywords/reservoir.tsx`, find the `<DataTable … />` usage and add these two props (alongside the existing props):

```tsx
            pageSize={50}
            pageSizeOptions={[20, 50, 100, 500]}
```

- [ ] **Step 2: backlink Réservoir**

Same edit in `src/components/backlink/reservoir-container.tsx` — add `pageSize={50}` and `pageSizeOptions={[20, 50, 100, 500]}` to its `<DataTable … />`.

- [ ] **Step 3: backlink Profil**

Same edit in `src/components/backlink/domain-profile-container.tsx`.

- [ ] **Step 4: Typecheck + lint + commit**

Run: `npx tsc --noEmit` → `No errors found`.
Run: `bunx biome check --write src/components/keywords/reservoir.tsx src/components/backlink/reservoir-container.tsx src/components/backlink/domain-profile-container.tsx` → clean.

```bash
git add src/components/keywords/reservoir.tsx src/components/backlink/reservoir-container.tsx src/components/backlink/domain-profile-container.tsx
git commit -m "feat(keywords,backlink): paginate Réservoir + Backlink tables"
```

---

## Task 7: Live verification + finish

- [ ] **Step 1: Full type + lint sweep**

Run: `npx tsc --noEmit` → `No errors found`.
Run: `bunx biome check src/lib/keywords/export-enrichment.ts src/components/keywords/export-enrichment-dialog.tsx src/components/ui/data-table.tsx src/components/keywords/research/research-container.tsx` → clean.

- [ ] **Step 2: Unit tests**

Run: `bun --conditions=react-server test src/lib/keywords/export-enrichment.test.ts` → 4 pass.

- [ ] **Step 3: Live check (d3k, dev server running on :3000, logged in on Acieb)**

- Open `http://localhost:3000/keywords/recherche`.
- Confirm pagination: a page-size selector showing **50**, controls top AND bottom, changing to 20/100/500 repaginates, prev/next works, "Page X / Y · N résultats" is correct, and a selection made on page 1 persists to page 2.
- Click **Exporter** → choose Mangools → the recap shows the missing-metric count + file count → **Télécharger** downloads a `.txt` (single chunk) or `.zip` (multiple). Verify the `.txt` contains one term per line and no keyword with metrics.
- Spot-check the other paginated tables render the controls (top + bottom) without layout breakage: keywords Réservoir (`/keywords/reservoir`), backlink Réservoir, backlink Profil.

- [ ] **Step 4: Finish the branch**

Use superpowers:finishing-a-development-branch (ff-merge `feature/recherche-pagination-export` → `dev`, push, delete branch). Mark task #25 completed.

---

## Notes / risks
- **Select gotcha:** `SelectContent` MUST use `position="popper"` (the default `item-aligned` pushes options above the viewport — a known issue in this repo).
- **`hasNoMetric` semantics** intentionally mirror the N/A table cell (`metrics[primarySource]?.volume == null`); a genuine 0 counts as present.
- **Selection across pages** is TanStack's default (keyed by `getRowId`, already `(k) => k.id`); the header "select all" already selects all filtered rows — no change needed.
- **Out of scope:** full-data CSV export, Réservoir pagination, automatic re-import of enriched metrics.
