# Content SERP Competitors (Slice 1) 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:** In the Planning content-card sheet, show a keyword's top-10 organic SERP competitors (backlinks/URL, ref.domains, authority, traffic) + SERP features, imported from a single SERP-URLs CSV export, with a toggle to the existing YTG brief.

**Architecture:** A new rust parser (`import-core` wasm) reads the SE-Ranking-style SERP-URLs CSV → `{features, competitors}`. A thin TS wrapper stamps provenance into a `SerpSnapshot`. Snapshots are stored as a new `serp` jsonb overlay on `keywords`, keyed by the keyword's country (mirrors `briefs`/`gsc`). Server actions import/read it; a `SerpPanel` component renders it inside `content-card-sheet.tsx`. On-page columns (meta/images/alt/JSON-LD) exist in the model but stay `null` (slice 2 = crawl).

**Tech Stack:** Rust (`csv` crate) → wasm-bindgen → `import-core` pkg; Drizzle/Postgres jsonb; Next 16 server actions; React client component; bun:test + cargo test.

---

### Task 1: SERP TypeScript types

**Files:**
- Create: `src/lib/keywords/serp-types.ts`

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

```ts
// src/lib/keywords/serp-types.ts — competitor SERP snapshot stored per locale on a keyword.
export interface SerpCompetitor {
  position: number; // organic rank
  domain: string;
  url: string;
  backlinks: number | null; // per-URL backlinks (decision: backlinks vers l'URL)
  refDomains: number | null; // "Ref.Domains"
  pageAuthority: number | null; // "Page AS"
  searchTraffic: number | null; // "Search Traffic"
  urlKeywords: number | null; // "URL Keywords"
  // slice 2 (crawl) — present but null in slice 1:
  title: string | null;
  metaDescription: string | null;
  images: number | null;
  imagesWithoutAlt: number | null;
  imageAlts: string[] | null; // alt text per image
  hasJsonLd: boolean | null; // JSON-LD / rich snippet
}

export interface SerpSnapshot {
  source: "se-ranking";
  importedAt: string; // ISO date
  features: string[]; // distinct non-organic SERP feature types (PAA, Video…)
  competitors: SerpCompetitor[]; // top 10 organic, sorted by position asc
}
```

- [ ] **Step 2: Typecheck**

Run: `bun run tsc --noEmit` (or `bunx tsc --noEmit`)
Expected: PASS (no new errors).

- [ ] **Step 3: Commit**

```bash
git add src/lib/keywords/serp-types.ts
git commit -m "feat(content): SERP competitor types (slice 1)"
```

---

### Task 2: Rust SERP parser + wasm export

**Files:**
- Create: `crates/import-core/src/serp.rs`
- Modify: `crates/import-core/src/lib.rs` (register module + wasm export)

- [ ] **Step 1: Write the parser with failing tests**

Create `crates/import-core/src/serp.rs`:

```rust
// crates/import-core/src/serp.rs — parse a SERP-URLs export (SE Ranking style).
use serde::Serialize;

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SerpCompetitor {
    pub position: i64,
    pub domain: String,
    pub url: String,
    pub backlinks: Option<i64>,
    pub ref_domains: Option<i64>,
    pub page_authority: Option<i64>,
    pub search_traffic: Option<i64>,
    pub url_keywords: Option<i64>,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SerpParseResult {
    pub features: Vec<String>,
    pub competitors: Vec<SerpCompetitor>,
}

/// Parse an integer cell, tolerating thousands separators / whitespace; empty -> None.
fn opt_i64(s: &str) -> Option<i64> {
    let t: String = s.chars().filter(|c| !c.is_whitespace() && *c != ',').collect();
    if t.is_empty() {
        return None;
    }
    t.parse().ok()
}

fn add_feature(features: &mut Vec<String>, f: &str) {
    let f = f.trim();
    if !f.is_empty() && f != "Organic" && !features.iter().any(|x| x == f) {
        features.push(f.to_string());
    }
}

pub fn parse_serp_urls(csv: &str) -> SerpParseResult {
    let mut rdr = csv::ReaderBuilder::new()
        .has_headers(true)
        .flexible(true)
        .trim(csv::Trim::All)
        .from_reader(csv.as_bytes());
    let headers = match rdr.headers() {
        Ok(h) => h.clone(),
        Err(_) => {
            return SerpParseResult {
                features: vec![],
                competitors: vec![],
            }
        }
    };
    let idx = |name: &str| headers.iter().position(|h| h == name);
    let (i_pos, i_type, i_domain, i_url) =
        (idx("Position"), idx("Type"), idx("Domain"), idx("URL"));
    let i_pas = idx("Page AS");
    let i_ref = idx("Ref.Domains");
    let i_bl = idx("Backlinks");
    let i_traffic = idx("Search Traffic");
    let i_kw = idx("URL Keywords");
    let i_feat = idx("SERP Features");

    let mut competitors = Vec::new();
    let mut features: Vec<String> = Vec::new();

    for rec in rdr.records() {
        let Ok(r) = rec else { continue };
        let get = |i: Option<usize>| i.and_then(|i| r.get(i)).unwrap_or("").to_string();
        add_feature(&mut features, &get(i_feat));
        let typ = get(i_type);
        if typ == "Organic" {
            let Some(position) = opt_i64(&get(i_pos)) else {
                continue;
            };
            competitors.push(SerpCompetitor {
                position,
                domain: get(i_domain),
                url: get(i_url),
                backlinks: opt_i64(&get(i_bl)),
                ref_domains: opt_i64(&get(i_ref)),
                page_authority: opt_i64(&get(i_pas)),
                search_traffic: opt_i64(&get(i_traffic)),
                url_keywords: opt_i64(&get(i_kw)),
            });
        } else if !typ.is_empty() {
            add_feature(&mut features, &typ);
        }
    }
    competitors.sort_by_key(|c| c.position);
    competitors.truncate(10);
    SerpParseResult {
        features,
        competitors,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const SAMPLE: &str = "Position,Type,Domain,URL,Page AS,Ref.Domains,Backlinks,Search Traffic,URL Keywords,SERP Features\n1,Organic,a.com,https://a.com/x,55,141,370,91265,2706,\n2,Organic,b.com,https://b.com/y,68,173,197,141,235,\n,People also ask,c.com,https://c.com/z,,,,,,\n,Video,d.com,https://d.com/v,,,,,,Video\n";

    #[test]
    fn parses_organic_and_features() {
        let r = parse_serp_urls(SAMPLE);
        assert_eq!(r.competitors.len(), 2);
        assert_eq!(r.competitors[0].position, 1);
        assert_eq!(r.competitors[0].domain, "a.com");
        assert_eq!(r.competitors[0].backlinks, Some(370));
        assert_eq!(r.competitors[0].ref_domains, Some(141));
        assert!(r.features.contains(&"People also ask".to_string()));
        assert!(r.features.contains(&"Video".to_string()));
        assert!(!r.features.contains(&"Organic".to_string()));
    }

    #[test]
    fn truncates_to_10_sorted() {
        let mut csv = String::from("Position,Type,Domain,URL,Page AS,Ref.Domains,Backlinks,Search Traffic,URL Keywords,SERP Features\n");
        for i in (1..=15).rev() {
            csv.push_str(&format!(
                "{i},Organic,d{i}.com,https://d{i}.com/,1,1,1,1,1,\n"
            ));
        }
        let r = parse_serp_urls(&csv);
        assert_eq!(r.competitors.len(), 10);
        assert_eq!(r.competitors[0].position, 1);
        assert_eq!(r.competitors[9].position, 10);
    }

    #[test]
    fn empty_numeric_cells_become_null() {
        let csv = "Position,Type,Domain,URL,Page AS,Ref.Domains,Backlinks,Search Traffic,URL Keywords,SERP Features\n1,Organic,a.com,https://a.com/,,,,,,\n";
        let r = parse_serp_urls(csv);
        assert_eq!(r.competitors[0].backlinks, None);
        assert_eq!(r.competitors[0].page_authority, None);
    }
}
```

- [ ] **Step 2: Register the module + wasm export in `crates/import-core/src/lib.rs`**

Add `mod serp;` next to the other `mod` lines at the top, and add this `#[wasm_bindgen]` export after `parse_keywords_csv`:

```rust
#[wasm_bindgen]
pub fn parse_serp_urls_csv(csv: &str) -> Result<JsValue, JsValue> {
    let res = serp::parse_serp_urls(csv);
    serde_wasm_bindgen::to_value(&res).map_err(|e| JsValue::from_str(&e.to_string()))
}
```

- [ ] **Step 3: Run rust tests**

Run: `cargo test --manifest-path crates/import-core/Cargo.toml serp`
Expected: PASS (3 tests: parses_organic_and_features, truncates_to_10_sorted, empty_numeric_cells_become_null).

- [ ] **Step 4: Rebuild wasm + relink (external — not HMR'd)**

Run:
```bash
bun run build:wasm
bun install
```
Expected: `wasm-pack` regenerates `src/lib/backlink/import-core/`; `import_core.d.ts` now lists `parse_serp_urls_csv`. Verify:
```bash
grep parse_serp_urls_csv src/lib/backlink/import-core/import_core.d.ts
```
Expected: one match.

- [ ] **Step 5: Restart d3k so the new wasm is loaded**

Run:
```bash
pkill -f dev3000; sleep 2
cd /Users/humperdoo/Github/seo-insight && d3k --startup-timeout 60 --browser-navigation-timeout 60 --browser-tool agent-browser --date-time local --agent-name claude-yolo --no-tui > /tmp/d3k-launch.log 2>&1 &
sleep 12; grep -m1 "ready" /tmp/d3k-launch.log
```
Expected: "Development environment ready!" and port 3000 serving.

- [ ] **Step 6: Commit**

```bash
git add crates/import-core/src/serp.rs crates/import-core/src/lib.rs src/lib/backlink/import-core
git commit -m "feat(content): rust SERP-URLs CSV parser + wasm export"
```

---

### Task 3: `serp` jsonb column on keywords + migration

**Files:**
- Modify: `src/lib/db/domain-schema.ts` (keywords table)

- [ ] **Step 1: Add the column**

In `src/lib/db/domain-schema.ts`, inside the `keywords` table (right after the `gsc` line), add:

```ts
  serp: jsonb("serp")
    .$type<Record<string, import("@/lib/keywords/serp-types").SerpSnapshot>>()
    .notNull()
    .default({}),
```

- [ ] **Step 2: Generate the migration**

Run: `bun run db:generate`
Expected: a new `drizzle/0009_*.sql` adding `serp jsonb not null default '{}'::jsonb` to `keywords`. Inspect it:
```bash
ls drizzle | tail -1
```

- [ ] **Step 3: Apply to dev + test DBs**

Run:
```bash
bun run db:migrate
bun run db:migrate:test
```
Expected: both report the new migration applied, no errors.

- [ ] **Step 4: Typecheck**

Run: `bunx tsc --noEmit`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/lib/db/domain-schema.ts drizzle
git commit -m "feat(content): add keywords.serp jsonb overlay"
```

---

### Task 4: TS wrapper (`parseSerpExport`) + test

**Files:**
- Create: `src/lib/keywords/serp-import.ts`
- Test: `src/lib/keywords/serp-import.test.ts`

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

```ts
// src/lib/keywords/serp-import.test.ts
import { describe, expect, it } from "bun:test";
import { parseSerpExport } from "./serp-import";

const CSV = `Position,Type,Domain,URL,Page AS,Ref.Domains,Backlinks,Search Traffic,URL Keywords,SERP Features
1,Organic,a.com,https://a.com/x,55,141,370,91265,2706,
,Video,d.com,https://d.com/v,,,,,,Video`;

describe("parseSerpExport", () => {
  it("stamps provenance + maps competitors with null on-page fields", () => {
    const snap = parseSerpExport(CSV, "2026-06-08T00:00:00.000Z");
    expect(snap.source).toBe("se-ranking");
    expect(snap.importedAt).toBe("2026-06-08T00:00:00.000Z");
    expect(snap.competitors).toHaveLength(1);
    expect(snap.competitors[0].backlinks).toBe(370);
    expect(snap.competitors[0].refDomains).toBe(141);
    expect(snap.competitors[0].title).toBeNull();
    expect(snap.competitors[0].imageAlts).toBeNull();
    expect(snap.features).toContain("Video");
  });
});
```

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

Run: `bun test src/lib/keywords/serp-import.test.ts`
Expected: FAIL ("Cannot find module './serp-import'").

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

```ts
// src/lib/keywords/serp-import.ts — wrap the wasm SERP parser, stamp provenance.
import * as wasm from "import-core";
import type { SerpCompetitor, SerpSnapshot } from "./serp-types";

interface RawCompetitor {
  position: number;
  domain: string;
  url: string;
  backlinks: number | null;
  refDomains: number | null;
  pageAuthority: number | null;
  searchTraffic: number | null;
  urlKeywords: number | null;
}
interface RawResult {
  features: string[];
  competitors: RawCompetitor[];
}

export function parseSerpExport(
  csvText: string,
  importedAt: string,
): SerpSnapshot {
  const raw = wasm.parse_serp_urls_csv(csvText) as RawResult;
  const competitors: SerpCompetitor[] = raw.competitors.map((c) => ({
    ...c,
    title: null,
    metaDescription: null,
    images: null,
    imagesWithoutAlt: null,
    imageAlts: null,
    hasJsonLd: null,
  }));
  return {
    source: "se-ranking",
    importedAt,
    features: raw.features,
    competitors,
  };
}
```

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

Run: `bun test src/lib/keywords/serp-import.test.ts`
Expected: PASS (1 test). (Requires Task 2 wasm rebuild — `parse_serp_urls_csv` must exist in the pkg.)

- [ ] **Step 5: Commit**

```bash
git add src/lib/keywords/serp-import.ts src/lib/keywords/serp-import.test.ts
git commit -m "feat(content): SERP export TS wrapper + test"
```

---

### Task 5: Server actions (import + read) + DB test

**Files:**
- Create: `src/lib/keywords/serp-actions.ts`
- Test: `src/lib/keywords/serp-actions.test.ts`

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

```ts
// src/lib/keywords/serp-actions.test.ts — integration, gated on DB_TESTS=1, *_test DB only.
import { beforeEach, describe, expect, mock, test } from "bun:test";

mock.module("server-only", () => ({}));
mock.module("next/cache", () => ({ revalidatePath: () => {} }));

const RUN_DB = process.env.DB_TESTS === "1";

const CSV = `Position,Type,Domain,URL,Page AS,Ref.Domains,Backlinks,Search Traffic,URL Keywords,SERP Features
1,Organic,a.com,https://a.com/x,55,141,370,91265,2706,
2,Organic,b.com,https://b.com/y,68,173,197,141,235,
,People also ask,c.com,https://c.com/z,,,,,,`;

describe.skipIf(!RUN_DB)("serp actions (postgres)", () => {
  let db: typeof import("@/lib/db/client").db;
  let schema: typeof import("@/lib/db/domain-schema");
  let actions: typeof import("./serp-actions");

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    schema = await import("@/lib/db/domain-schema");
    actions = await import("./serp-actions");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();
    await db.delete(schema.keywords);
    await db.delete(schema.sites);
    await db.insert(schema.sites).values({
      id: "site-a",
      orgId: "demo-org",
      ownerUserId: "seed-owner",
      name: "Site A",
      primaryLanguage: "fr",
      languages: [],
      targetCountries: ["FR"],
    });
    await db.insert(schema.keywords).values({
      id: "kw-1",
      siteId: "site-a",
      term: "comparateur electricite pro",
      lang: "fr",
      country: "FR",
      status: "active",
      intent: ["commercial"],
      primarySource: "mangools",
      sources: ["mangools"],
      metrics: {},
      trend: [],
    });
  });

  test("importSerpForKeywordAction stores a snapshot under the keyword's country", async () => {
    const res = await actions.importSerpForKeywordAction("kw-1", CSV);
    expect(res.ok).toBe(true);
    expect(res.locale).toBe("FR");
    const serp = await actions.getSerpForKeywordAction("kw-1");
    expect(serp.FR.competitors).toHaveLength(2);
    expect(serp.FR.competitors[0].backlinks).toBe(370);
    expect(serp.FR.features).toContain("People also ask");
    expect(serp.FR.source).toBe("se-ranking");
  });

  test("importSerpForKeywordAction rejects an export with no organic rows", async () => {
    const res = await actions.importSerpForKeywordAction(
      "kw-1",
      "Position,Type,Domain,URL,Page AS,Ref.Domains,Backlinks,Search Traffic,URL Keywords,SERP Features\n,Video,d.com,https://d.com/,,,,,,Video",
    );
    expect(res.ok).toBe(false);
  });
});
```

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

Run: `bun run test src/lib/keywords/serp-actions.test.ts`
Expected: FAIL ("Cannot find module './serp-actions'").

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

```ts
// src/lib/keywords/serp-actions.ts — import/read competitor SERP snapshots on a keyword.
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db/client";
import { keywords } from "@/lib/db/domain-schema";
import { parseSerpExport } from "./serp-import";
import type { SerpSnapshot } from "./serp-types";

export async function getSerpForKeywordAction(
  keywordId: string,
): Promise<Record<string, SerpSnapshot>> {
  const [row] = await db
    .select({ serp: keywords.serp })
    .from(keywords)
    .where(eq(keywords.id, keywordId));
  return row?.serp ?? {};
}

export async function importSerpForKeywordAction(
  keywordId: string,
  csvText: string,
): Promise<{ ok: boolean; locale?: string; error?: string }> {
  const [row] = await db
    .select({ serp: keywords.serp, country: keywords.country })
    .from(keywords)
    .where(eq(keywords.id, keywordId));
  if (!row) return { ok: false, error: "Mot-clé introuvable" };

  let snapshot: SerpSnapshot;
  try {
    snapshot = parseSerpExport(csvText, new Date().toISOString());
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e.message : "Parse échoué" };
  }
  if (snapshot.competitors.length === 0) {
    return { ok: false, error: "Aucun concurrent organique dans l'export" };
  }

  const locale = row.country || "US";
  const next = { ...(row.serp ?? {}), [locale]: snapshot };
  await db.update(keywords).set({ serp: next }).where(eq(keywords.id, keywordId));
  revalidatePath("/content/planning");
  return { ok: true, locale };
}
```

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

Run: `bun run test src/lib/keywords/serp-actions.test.ts`
Expected: PASS (2 tests). (`bun run test` uses the test DB + `.env.local`.)

- [ ] **Step 5: Commit**

```bash
git add src/lib/keywords/serp-actions.ts src/lib/keywords/serp-actions.test.ts
git commit -m "feat(content): SERP import/read server actions"
```

---

### Task 6: `SerpPanel` component + wire into the content-card sheet

**Files:**
- Create: `src/components/content/serp-panel.tsx`
- Modify: `src/components/content/content-card-sheet.tsx`

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

```tsx
// src/components/content/serp-panel.tsx — competitor SERP table for a keyword locale.
"use client";

import { useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { importSerpForKeywordAction } from "@/lib/keywords/serp-actions";
import type { SerpSnapshot } from "@/lib/keywords/serp-types";

function fmt(n: number | null): string {
  return n == null ? "—" : n.toLocaleString("fr-FR");
}

export function SerpPanel({
  keywordId,
  snapshot,
  onImported,
}: {
  keywordId: string;
  snapshot: SerpSnapshot | null;
  onImported: () => void;
}) {
  const inputRef = useRef<HTMLInputElement>(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    e.target.value = "";
    if (!file) return;
    setBusy(true);
    setError(null);
    try {
      const text = await file.text();
      const res = await importSerpForKeywordAction(keywordId, text);
      if (!res.ok) setError(res.error ?? "Import échoué");
      else onImported();
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="rounded-lg border p-3">
      <div className="mb-2 flex items-center justify-between">
        <div className="font-medium text-sm">SERP / Concurrents</div>
        <Button
          size="sm"
          variant="outline"
          disabled={busy}
          onClick={() => inputRef.current?.click()}
        >
          {busy ? "Import…" : "Importer export SERP"}
        </Button>
        <input
          ref={inputRef}
          type="file"
          accept=".csv,text/csv"
          className="hidden"
          onChange={onFile}
        />
      </div>

      {error ? <p className="mb-2 text-destructive text-xs">{error}</p> : null}

      {!snapshot ? (
        <p className="text-muted-foreground text-sm">
          Aucune SERP importée. Dépose un export “SERP URLs”.
        </p>
      ) : (
        <>
          {snapshot.features.length > 0 ? (
            <div className="mb-2 flex flex-wrap gap-1">
              {snapshot.features.map((f) => (
                <Badge key={f} variant="secondary">
                  {f}
                </Badge>
              ))}
            </div>
          ) : null}
          <div className="overflow-x-auto">
            <table className="w-full text-xs">
              <thead className="text-muted-foreground">
                <tr className="border-b text-left">
                  <th className="py-1 pr-2">#</th>
                  <th className="py-1 pr-2">Domaine</th>
                  <th className="py-1 pr-2 text-right">Backlinks</th>
                  <th className="py-1 pr-2 text-right">Ref.dom</th>
                  <th className="py-1 pr-2 text-right">Page AS</th>
                  <th className="py-1 pr-2 text-right">Trafic</th>
                  <th className="py-1 pr-2">Meta</th>
                  <th className="py-1 pr-2">Img</th>
                  <th className="py-1 pr-2">Alt</th>
                  <th className="py-1">JSON-LD</th>
                </tr>
              </thead>
              <tbody>
                {snapshot.competitors.map((c) => (
                  <tr key={c.url} className="border-b last:border-0">
                    <td className="py-1 pr-2">{c.position}</td>
                    <td className="py-1 pr-2">
                      <a
                        href={c.url}
                        target="_blank"
                        rel="noopener noreferrer"
                        className="text-primary hover:underline"
                      >
                        {c.domain}
                      </a>
                    </td>
                    <td className="py-1 pr-2 text-right">{fmt(c.backlinks)}</td>
                    <td className="py-1 pr-2 text-right">{fmt(c.refDomains)}</td>
                    <td className="py-1 pr-2 text-right">
                      {fmt(c.pageAuthority)}
                    </td>
                    <td className="py-1 pr-2 text-right">
                      {fmt(c.searchTraffic)}
                    </td>
                    <td className="py-1 pr-2 text-muted-foreground">—</td>
                    <td className="py-1 pr-2 text-muted-foreground">—</td>
                    <td className="py-1 pr-2 text-muted-foreground">—</td>
                    <td className="py-1 text-muted-foreground">—</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          <p className="mt-2 text-muted-foreground text-[11px]">
            Meta / Img / Alt / JSON-LD : à crawler (slice 2).
          </p>
        </>
      )}
    </div>
  );
}
```

- [ ] **Step 2: Wire it into `content-card-sheet.tsx`**

Add imports near the existing ones:

```tsx
import { SerpPanel } from "@/components/content/serp-panel";
import { getSerpForKeywordAction } from "@/lib/keywords/serp-actions";
import type { SerpSnapshot } from "@/lib/keywords/serp-types";
```

Add state next to `briefs`:

```tsx
  const [serp, setSerp] = useState<SerpSnapshot | null>(null);
```

In the `useEffect`, after `setBriefs(null);`, add `setSerp(null);`, and inside the `if (item.primaryKeywordId) {` block add a parallel fetch keyed by the item's country:

```tsx
      getSerpForKeywordAction(item.primaryKeywordId)
        .then((rec) => {
          const key = item.keywordCountry ?? item.country;
          setSerp(rec[key] ?? Object.values(rec)[0] ?? null);
        })
        .catch(() => setSerp(null));
```

Add a `reloadSerp` helper (after `save`/`remove`) so the panel can refresh after import:

```tsx
  function reloadSerp() {
    if (!item?.primaryKeywordId) return;
    getSerpForKeywordAction(item.primaryKeywordId)
      .then((rec) => {
        const key = item.keywordCountry ?? item.country;
        setSerp(rec[key] ?? Object.values(rec)[0] ?? null);
      })
      .catch(() => {});
  }
```

Render the panel after the keyword line (replace the `{item.keywordTerm ? … : null}` block's sibling area) — insert right after that block, only when there is a linked keyword:

```tsx
          {item.primaryKeywordId ? (
            <SerpPanel
              keywordId={item.primaryKeywordId}
              snapshot={serp}
              onImported={reloadSerp}
            />
          ) : null}
```

(The existing `briefs` block — the YTG brief toggle — stays directly below, unchanged.)

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

Run: `bunx tsc --noEmit && bunx biome check src/components/content/serp-panel.tsx src/components/content/content-card-sheet.tsx`
Expected: PASS (no errors).

- [ ] **Step 4: Commit**

```bash
git add src/components/content/serp-panel.tsx src/components/content/content-card-sheet.tsx
git commit -m "feat(content): SERP competitors panel in content-card sheet"
```

---

### Task 7: Manual verification (d3k) + full test sweep

**Files:** none (verification only).

- [ ] **Step 1: Full test + typecheck sweep**

Run:
```bash
bunx tsc --noEmit
bun run test src/lib/keywords/serp-import.test.ts src/lib/keywords/serp-actions.test.ts
cargo test --manifest-path crates/import-core/Cargo.toml serp
```
Expected: tsc clean; bun tests pass; cargo serp tests pass.

- [ ] **Step 2: Seed a verifiable case (acieb site)**

In a `bun --env-file=.env.local` repl or a throwaway script: find a keyword on the acieb site with `country="FR"` (or set one), note its `id`. Open the app at `http://localhost:3000/content/planning`, ensure a content item is linked to that keyword (create one if needed via the board), open its card.

- [ ] **Step 3: Import the real export + verify via d3k**

In the card's “SERP / Concurrents” section, click **Importer export SERP** and choose
`/Users/humperdoo/Downloads/export-kw/comparateur-energie-pro_serp_urls_fr_2026-06-08_11-01-27.csv`.

Verify with d3k (`agent-browser` eval / screenshot):
- top-10 organic rows render, position 1 = `service-public.gouv.fr`, backlinks `370`, ref.dom `141`;
- SERP-feature badge shows `People also ask` (and/or `Video`);
- Meta / Img / Alt / JSON-LD columns show “—”;
- the YTG brief block still renders below when the keyword has a brief.

Run: `d3k errors` (or read the live log) → expected clean (no runtime/console errors).

- [ ] **Step 4: Clean up any throwaway seed** (delete the temp content item/keyword tweak if it isn't real data).

- [ ] **Step 5: Final commit (if cleanup produced changes) — otherwise nothing to commit.**

---

## Done criteria

- `keywords.serp` jsonb migration applied (dev + test).
- Rust `parse_serp_urls` + wasm `parse_serp_urls_csv` shipped; cargo tests green.
- `parseSerpExport`, `importSerpForKeywordAction`, `getSerpForKeywordAction` shipped + tested.
- `SerpPanel` renders top-10 competitors + features + on-page “—” placeholders in the Planning content-card sheet; YTG brief toggle intact.
- tsc clean, all touched tests pass, d3k verified on the real export, `d3k errors` clean.
- Branch `feature/content-serp-competitors` merged into `dev` (ff-only), deleted, pushed.
