# Import des listes Mangools — plan d'implémentation

> **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:** Permettre, depuis l'étape 3 du parcours d'entrée, de lister ses listes Mangools, d'en sélectionner, de leur affecter un thème, et de les importer avec leurs métriques.

**Architecture:** Deux méthodes s'ajoutent au client Mangools existant. Un module pur transforme une liste en lignes de mots-clés, en réutilisant les normaliseurs déjà écrits. L'écriture en base reprend le motif éprouvé de `related-actions.ts` — quota hors transaction, découpage, fusion profonde par source. L'interface de l'étape 3 passe de deux chemins à trois.

**Tech Stack:** Next.js 16 (App Router, Server Actions), Drizzle ORM + Postgres, bun test, Biome, shadcn.

**Spec:** `docs/superpowers/specs/2026-07-28-onboarding-mangools-lists-design.md` — la lire avant de commencer, en particulier la section sur la forme réelle des réponses.

## Global Constraints

- Gestionnaire de paquets : **bun**. Jamais `npm`/`npx` (échoue en `EOVERRIDE`). Utilise `bunx`.
- Tests : `bun run test <chemin>` (le script pose `DB_TESTS=1`, la base de test et `--conditions=react-server`). Jamais `bun test` nu. La porte verte de la branche est `bun run test:all`, qui isole par répertoire.
- Commits au format **Conventional Commits**. Commentaires et libellés en **français**, identifiants en anglais.
- i18n : `messages/en.json` **et** `messages/fr.json`, mêmes clés, même arborescence. Seules ces deux locales existent. **Une clé manquante n'est détectée que par `bun run build`, pas par `tsc`.**
- Composants shadcn uniquement, jamais Radix en direct. Tout `SelectContent` porte `position="popper"`.
- `bunx tsc --noEmit` à zéro **et** `bun run build` vert en fin de chaque tâche qui touche l'interface.
- Lint : `bunx biome check <fichiers touchés>`. La base porte des erreurs pré-existantes hors périmètre — ne pas les corriger.

## Les faits relevés sur l'API, à ne pas re-supposer

Ces valeurs viennent d'un compte réel, pas de la documentation, **qui est tronquée et fausse sur deux points** :

- `GET /kwfinder/lists` rend un **tableau nu** d'objets `{ _id, name, user_id, creator, created_at, updated_at, __v, keyword_ids }`.
- `GET /kwfinder/lists/{list_id}` rend `{ list, keywords }`.
- **Les horodatages de l'enveloppe sont en millisecondes** (`1784185070000`), ceux des mots-clés en **secondes** (`ts: 1784185097`). Même instant, deux unités, même document.
- Chaque mot-clé porte `location: { _id, code, label }` — `code` est l'alpha-2 en minuscules (`"us"`).
- Le nombre de mots-clés est connu dès le listage par `keyword_ids.length`. Écart observé : de 21 à 2106.

---

### Task 1 : Les deux appels du client

**Files:**
- Modify: `src/lib/mangools/types.ts`
- Modify: `src/lib/mangools/client.ts`
- Modify: `src/lib/mangools/client.test.ts`

**Interfaces:**
- Produces :
```ts
export type MangoolsList = {
  id: string;          // `_id` de l'API
  name: string;
  keywordCount: number; // keyword_ids.length
  createdAt: string;    // ISO — la source est en MILLISECONDES
  updatedAt: string;    // ISO — idem
};
export type MangoolsListDetail = {
  list: MangoolsList;
  keywords: MangoolsKeywordItem[];
  /** Codes pays alpha-2 MAJUSCULES rencontrés dans la liste, dédupliqués. */
  countries: string[];
};
// sur MangoolsClient :
lists(): Promise<MangoolsList[]>;
list(listId: string): Promise<MangoolsListDetail>;
```

`MangoolsKeywordItem` existe déjà et `mapKeywordItem` (`client.ts:91`) mappe **exactement** la forme des mots-clés d'une liste. **Réutilise-le, n'écris pas de second mappeur.**

- [ ] **Step 1: Écrire le test**

Dans `src/lib/mangools/client.test.ts`, à la suite des tests existants (même motif d'injection de `fetch`) :

```ts
const LIST_RAW = {
  _id: "6a5880eeeefecfa46ace107d",
  name: "ADVMFR USA Top",
  user_id: "675679b76aee08cdea6d04e0",
  creator: "675679b76aee08cdea6d04e0",
  created_at: 1784185070000,
  updated_at: 1784606505000,
  __v: 0,
  keyword_ids: ["a", "b", "c"],
};
const KW_RAW = {
  _id: "08b7c71d5b0b37413c69a028a787f388",
  kw: "jewelry wholesale manufacturer",
  lid: 2840,
  location: { _id: 2840, code: "us", label: "United States" },
  seo: 29,
  sv: 210,
  cpc: 2.83,
  ppc: 91,
  msv: [[2024, 0, 320]],
  ts: 1784185097,
};

test("lists() projette l'enveloppe et compte les mots-clés", async () => {
  const client = createMangoolsClient("tok", {
    fetch: async () => new Response(JSON.stringify([LIST_RAW])),
    throttle: instantThrottle(),
  });
  const [l] = await client.lists();
  expect(l.id).toBe("6a5880eeeefecfa46ace107d");
  expect(l.name).toBe("ADVMFR USA Top");
  expect(l.keywordCount).toBe(3);
});

test("lists() traite les horodatages en MILLISECONDES", async () => {
  const client = createMangoolsClient("tok", {
    fetch: async () => new Response(JSON.stringify([LIST_RAW])),
    throttle: instantThrottle(),
  });
  const [l] = await client.lists();
  // 1784185070000 ms = 2026-07-14. Traité en secondes, on obtiendrait 1970.
  expect(l.createdAt.startsWith("2026-")).toBe(true);
  expect(l.updatedAt.startsWith("2026-")).toBe(true);
});

test("lists() sur un compte sans liste rend un tableau vide", async () => {
  const client = createMangoolsClient("tok", {
    fetch: async () => new Response(JSON.stringify([])),
    throttle: instantThrottle(),
  });
  expect(await client.lists()).toEqual([]);
});

test("list() rend les mots-clés et les pays rencontrés, en majuscules", async () => {
  const client = createMangoolsClient("tok", {
    fetch: async () =>
      new Response(JSON.stringify({ list: LIST_RAW, keywords: [KW_RAW] })),
    throttle: instantThrottle(),
  });
  const d = await client.list("6a5880eeeefecfa46ace107d");
  expect(d.keywords[0]?.kw).toBe("jewelry wholesale manufacturer");
  expect(d.keywords[0]?.sv).toBe(210);
  expect(d.countries).toEqual(["US"]);
});

test("list() déduplique les pays d'une liste mélangée", async () => {
  const fr = { ...KW_RAW, _id: "x", location: { _id: 1, code: "fr", label: "France" } };
  const client = createMangoolsClient("tok", {
    fetch: async () =>
      new Response(JSON.stringify({ list: LIST_RAW, keywords: [KW_RAW, fr, KW_RAW] })),
    throttle: instantThrottle(),
  });
  expect((await client.list("id")).countries.sort()).toEqual(["FR", "US"]);
});

test("list() sur 401 lève une MangoolsApiError unauthorized", async () => {
  const client = createMangoolsClient("tok", {
    fetch: async () => new Response("", { status: 401 }),
    throttle: instantThrottle(),
  });
  await expect(client.list("id")).rejects.toMatchObject({ code: "unauthorized" });
});
```

Réutilise l'utilitaire de throttle instantané déjà présent dans ce fichier de test ; s'il porte un autre nom, garde le sien.

- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/mangools/client.test.ts`
Expected: FAIL — `lists` et `list` n'existent pas sur le client.

- [ ] **Step 3: Ajouter les types**

Dans `src/lib/mangools/types.ts`, à côté des types existants, les trois déclarations du bloc **Interfaces** ci-dessus. Ajoute `lists` et `list` à l'interface `MangoolsClient`.

- [ ] **Step 4: Implémenter dans le client**

Dans `src/lib/mangools/client.ts`, un mappeur d'enveloppe puis les deux méthodes, à la suite de `searchLocations` :

```ts
/** Enveloppe de liste. ATTENTION : `created_at`/`updated_at` sont en
 *  MILLISECONDES ici, alors que `ts`/`seo_ts` des mots-clés sont en SECONDES.
 *  La documentation publiée montre des secondes pour les deux — elle est
 *  fausse, vérifié sur un compte réel le 2026-07-28. */
function mapList(raw: Record<string, unknown>): MangoolsList {
  const ids = Array.isArray(raw.keyword_ids) ? raw.keyword_ids : [];
  return {
    id: raw._id as string,
    name: (raw.name as string) ?? "",
    keywordCount: ids.length,
    createdAt: new Date(Number(raw.created_at ?? 0)).toISOString(),
    updatedAt: new Date(Number(raw.updated_at ?? raw.created_at ?? 0)).toISOString(),
  };
}
```

et, dans l'objet retourné :

```ts
    async lists() {
      const body = await request<Record<string, unknown>[]>("/kwfinder/lists");
      return (Array.isArray(body) ? body : []).map(mapList);
    },

    async list(listId) {
      const body = await request<{
        list?: Record<string, unknown>;
        keywords?: Record<string, unknown>[];
      }>(`/kwfinder/lists/${encodeURIComponent(listId)}`);
      const keywords = (body.keywords ?? []).map(mapKeywordItem);
      // Les pays viennent des mots-clés : l'enveloppe n'en porte aucun. Une
      // liste est mono-pays en pratique, mais rien ne le garantit côté API.
      const countries = [
        ...new Set(
          (body.keywords ?? [])
            .map((k) => (k.location as { code?: string } | undefined)?.code)
            .filter((c): c is string => typeof c === "string" && c !== "")
            .map((c) => c.toUpperCase()),
        ),
      ];
      return { list: mapList(body.list ?? {}), keywords, countries };
    },
```

Les deux passent par `request()`, donc héritent du throttle, de la reprise unique sur 429 et du délai de 20 s. **Ne les court-circuite pas.**

- [ ] **Step 5: Lancer les tests**

Run: `bun run test src/lib/mangools/`
Expected: PASS, y compris les suites existantes.

- [ ] **Step 6: Commit**

```bash
git add src/lib/mangools/types.ts src/lib/mangools/client.ts src/lib/mangools/client.test.ts
git commit -m "feat(mangools): lister les listes enregistrees et leur contenu

Les horodatages de l'enveloppe sont en millisecondes, ceux des mots-cles
en secondes — la doc publiee montre des secondes pour les deux."
```

---

### Task 2 : Transformer une liste en lignes de mots-clés

Un module **pur**, testable sans base ni réseau, qui décide de tout : le pays, les métriques, la tendance, les rejets.

**Files:**
- Create: `src/lib/mangools/list-import.ts`
- Create: `src/lib/mangools/list-import.test.ts`

**Interfaces:**
- Consumes : `MangoolsListDetail` (Task 1), `metricsFromMangools` et `trendFromMangools` (`src/lib/mangools/normalize.ts`), `slugify` (wasm), `keywordId`.
- Produces :
```ts
export type CountryVerdict =
  | { kind: "ok"; country: string }
  | { kind: "mixed"; countries: string[] }
  | { kind: "off-target"; country: string; siteCountries: string[] }
  | { kind: "unknown" };

export function resolveListCountry(
  listCountries: string[],
  siteCountries: string[],
): CountryVerdict;
```

**La règle du pays, et pourquoi elle compte.** Le chemin voisin (`related-actions.ts:106`) code en dur `siteRow.targetCountries?.[0] || "US"` — c'est le couple « langue fr » + « pays US » que ce projet a corrigé deux fois, et il ignore la localisation Mangools alors qu'elle est disponible. Ici :

- un seul pays, présent dans les pays cibles du site → `ok` ;
- un seul pays, **absent** des pays cibles du site → `off-target` : on importe quand même, mais on le dit avant ;
- plusieurs pays dans la liste → `mixed` : on ne choisit pas à la place de l'utilisateur ;
- aucun pays lisible → `unknown`.

**Les métriques.** Utilise `metricsFromMangools(item)` et `trendFromMangools(item)`, qui prennent l'objet Mangools **complet**. Le chemin voisin pose `growthPct: null` parce que le dialogue ne lui transmet qu'une forme réduite ; ici la série `msv` est présente, donc la croissance est calculée. Ne recopie pas le mapping à la main de `related-actions.ts`.

- [ ] **Step 1: Écrire le test**

Créer `src/lib/mangools/list-import.test.ts` couvrant les quatre verdicts, plus le mapping :

```ts
import { describe, expect, it } from "bun:test";
import { resolveListCountry } from "./list-import";

describe("resolveListCountry", () => {
  it("accepte un pays unique présent dans les cibles du site", () => {
    expect(resolveListCountry(["US"], ["US", "FR"])).toEqual({ kind: "ok", country: "US" });
  });

  it("signale un pays hors des cibles du site plutôt que de le taire", () => {
    expect(resolveListCountry(["US"], ["FR"])).toEqual({
      kind: "off-target",
      country: "US",
      siteCountries: ["FR"],
    });
  });

  it("refuse de choisir quand la liste mélange plusieurs pays", () => {
    expect(resolveListCountry(["US", "FR"], ["FR"])).toEqual({
      kind: "mixed",
      countries: ["FR", "US"],
    });
  });

  it("rend unknown quand aucun pays n'est lisible", () => {
    expect(resolveListCountry([], ["FR"])).toEqual({ kind: "unknown" });
  });
});
```

Ajoute les tests de mapping : un mot-clé complet produit une ligne avec `growthPct` non nul (la série `msv` est là), un terme vide est écarté, un terme de plus de 512 caractères est écarté, et deux termes qui donnent le même identifiant sont dédupliqués — mêmes garde-fous que `related-actions.ts:115-130`, que tu peux reprendre.

- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/mangools/list-import.test.ts`
Expected: FAIL — le module n'existe pas.

- [ ] **Step 3: Écrire le module**

Pur : aucune importation de `db`, ni de `server-only`. Il prend le détail d'une liste, le pays résolu, le site et le thème, et rend les lignes prêtes à insérer plus le verdict de pays.

- [ ] **Step 4: Lancer les tests**

Run: `bun run test src/lib/mangools/`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/lib/mangools/list-import.ts src/lib/mangools/list-import.test.ts
git commit -m "feat(mangools): transformer une liste en lignes de mots-cles

Le pays vient des mots-cles, pas d'un repli arbitraire : un ecart avec
les cibles du site est signale, une liste multi-pays n'est pas tranchee
d'office."
```

---

### Task 3 : Les actions serveur

**Files:**
- Create: `src/lib/mangools/list-actions.ts`
- Create: `src/lib/mangools/list-actions.test.ts`

**Interfaces:**
- Produces :
```ts
export async function listMangoolsListsAction(): Promise<
  | { ok: true; lists: MangoolsList[] }
  | { ok: false; error: string }
>;
export async function importMangoolsListsAction(input: {
  siteId: string;
  selections: { listId: string; themeSlug: string | null; themeLabel: string | null }[];
}): Promise<
  | { ok: true; inserted: number; verdicts: Record<string, CountryVerdict> }
  | { ok: false; error: string; quota?: QuotaExceeded }
>;
```

**Ne jette pas la localisation par mot-clé.** Relevé à la revue de la tâche 2 : `mapKeywordItem` n'expose pas `location.code`, si bien que le verdict « plusieurs pays » ne peut être que du tout-ou-rien. Une liste de 2000 mots-clés dont deux traînent sur une autre locale se retrouve entièrement bloquée, sans issue dans le produit. Ajoute `country` (alpha-2 majuscules) à `MangoolsKeywordItem` et à son mappeur — une ligne, aucun changement de comportement — pour qu'une évolution ultérieure puisse proposer « importer chaque mot-clé sur son propre pays », qui ne devine pas davantage que les autres verdicts.

**⚠️ Dédupliquer ENTRE les listes, pas seulement à l'intérieur.** Relevé à la tâche 2. `planListImport` déduplique par identifiant *au sein d'une liste*. Deux listes cochées ensemble qui partagent un terme produisent le **même identifiant** dans deux plans distincts ; réunis dans un seul `INSERT ... ON CONFLICT`, Postgres lève `ON CONFLICT DO UPDATE command cannot affect row a second time` — une erreur brute, pas un refus propre.

Ce n'est pas un cas de bord : sur le compte de référence, deux listes s'appellent « Advmfr » et « Top Advmfr », la seconde étant vraisemblablement un extrait de la première. Fusionne les plans **avant** l'insertion, en gardant la première occurrence, et couvre le cas par un test avec deux listes qui se recouvrent.

**Rafraîchir la tendance au ré-import.** Le module pose `trendBySource: { mangools: trend }`, mais la clause de conflit reprise de `related-actions.ts` ne touche pas ce champ : un ré-import ne rafraîchirait donc pas la courbe. Ajoute-la à la clause, avec la même sémantique de fusion par source que `metrics`.

**Un garde-fou à poser ici, relevé à la tâche 1.** `mapList` caste `_id` sans le valider — cohérent avec tous les mappeurs du fichier, donc laissé tel quel là-bas. Mais **c'est ta tâche qui enchaîne les deux appels** : une enveloppe sans identifiant produirait `client.list(undefined)`, donc une requête vers `/kwfinder/lists/undefined` au lieu d'un échec explicite. Écarte les listes sans identifiant utilisable avant de les rendre au client, et rappelle-toi que l'absence de ce champ dans la documentation est précisément ce qui a failli bloquer ce chantier.

**Trois contraintes non négociables :**

1. **La projection se fait côté serveur.** La réponse de listage porte le tableau complet des identifiants de mots-clés — près de cent kilooctets pour six listes, dont on n'utilise que la longueur. `listMangoolsListsAction` rend `MangoolsList[]`, jamais la charge brute.
2. **La limite d'un mégaoctet des Server Actions ne s'applique pas ici**, contrairement à l'import CSV : la charge cliente est minuscule (identifiants de listes et thèmes), les mots-clés sont récupérés **côté serveur**. Ne monte pas de route de fichier. Ce qui compte, c'est la durée du travail serveur, pas la taille de la requête.
3. **L'écriture reprend le motif de `related-actions.ts:160-215`**, sans l'inventer à nouveau : limite de quota résolue **avant** `db.transaction` (la résoudre dedans ouvre une seconde connexion et met le pool en attente — voir la documentation de `assertWithinQuotaTx`), verrou d'organisation, comptage des seuls mots-clés réellement nouveaux, découpage par tranches de 1000 sous le plafond de paramètres Postgres, et la même clause de conflit — fusion profonde de la sous-clé `mangools`, bascule de `primarySource` seulement si l'actuelle n'a pas de volume, union de `sources` et `stages`.

- [ ] **Step 1: Écrire les tests**

Suite DB, gardée par `describe.skipIf(!RUN_DB)`, avec injection du client Mangools plutôt que `mock.module` — le répertoire en contient déjà et il fuit d'un fichier à l'autre sous bun. Couvre : refus sans jeton connecté, refus sur un site hors de l'organisation active, import nominal qui insère et rend le décompte, réimport qui met à jour sans doubler le quota, et dépassement de quota qui rend `quota` sans écrire.

- [ ] **Step 2: Lancer pour vérifier l'échec**

Run: `bun run test src/lib/mangools/list-actions.test.ts`
Expected: FAIL.

- [ ] **Step 3: Écrire les actions**

Gate d'accès : `currentUserCanAccessSite(siteId)` **et** vérification que le site appartient à l'organisation active — `currentUserCanAccessSite` répond sur l'organisation **du site**, pas sur l'active ; utilisée seule elle laisserait fuiter les données d'une autre organisation dans la session courante. C'est exactement la régression corrigée sur la branche Search Console.

- [ ] **Step 4: Lancer les tests**

Run: `bun run test src/lib/mangools/`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/lib/mangools/list-actions.ts src/lib/mangools/list-actions.test.ts
git commit -m "feat(mangools): actions de listage et d'import des listes"
```

---

### Task 4 : Créer un thème à la volée

**Files:**
- Modify: `src/lib/keywords/actions.ts` (près de l'insertion existante, vers la ligne 536)
- Modify: le test correspondant

Un site neuf n'a **aucun** thème : `keyword_themes` est une table par site. Sans création sur place, le champ de thème de l'étape 3 serait vide et donc inutile — c'est la raison d'être de cette tâche.

**⚠️ Le slug doit être calculé par le même slugifieur que la tâche 3.** Relevé à son exécution : elle slugifie le libellé avec `wasm.slugify` et pose le résultat sur `keywords.theme`. Il n'y a **aucune clé étrangère** entre `keywords.theme` et `keyword_themes.slug`, donc une divergence ne lèverait rien : le thème existerait sur les mots-clés sans apparaître dans la liste des thèmes du site, et les filtres du réservoir ne le trouveraient jamais. Utilise le même appel, pas une normalisation maison.

L'identifiant d'une ligne est `${siteId}-${slug}`. La création est **idempotente** : deux listes portant le même nom de thème ne doivent pas échouer sur un conflit de clé.

- [ ] **Step 1: Écrire le test** — création, ré-utilisation d'un thème existant, idempotence sur un même libellé, et rejet d'un libellé vide.
- [ ] **Step 2: Vérifier le rouge.**
- [ ] **Step 3: Implémenter.**
- [ ] **Step 4: Vérifier le vert.** Run: `bun run test src/lib/keywords/`
- [ ] **Step 5: Commit.**

---

### Task 5 : L'étape 3 gagne un troisième chemin

**Files:**
- Modify: `src/components/onboarding/steps/keywords-step.tsx`
- Modify: `src/components/onboarding/steps/keywords-step-logic.ts`
- Modify: `src/components/onboarding/steps/keywords-step.test.ts`
- Modify: `src/app/onboarding/page.tsx`
- Modify: `messages/en.json`, `messages/fr.json`

**Ce que l'utilisateur voit**, dans l'ordre :

1. **Sans jeton Mangools** — trois cartes : importer depuis Mangools, déposer un CSV, saisir à la main. Choisir Mangools affiche le champ de jeton **sur place**, avec le lien vers la page qui le délivre. Réutilise le flux de connexion existant (`connectMangoolsAction`), ne le réécris pas.
2. **Jeton connecté, aucune liste** — le dire sans prétendre que le compte est vide si l'appel a échoué : ce sont deux choses différentes, distinction déjà tenue par l'étape Search Console et à reprendre ici.
3. **Listes affichées** — nom, nombre de mots-clés, date de mise à jour. Cases à cocher. Pour chaque liste cochée, un champ de thème pré-rempli avec le nom de la liste, qui accepte un thème existant ou un nouveau.

   **⚠️ N'essaie pas de réutiliser `ThemeSelect`.** Relevé à la tâche 4 : son fournisseur de libellés n'est monté que dans `src/app/(app)/layout.tsx`, et `/onboarding` vit **hors** de ce groupe de routes. Tiens le champ en état local — la tâche 4 a livré un chemin serveur qui crée le thème **pendant** l'import, donc aucun aller-retour n'est nécessaire pour le créer d'abord. Monter le fournisseur juste pour ce champ élargirait le périmètre sans rien apporter.
4. **Verdicts de pays** — une liste hors des pays cibles du site, ou mélangeant plusieurs pays, l'affiche **avant** l'import, pas après.

**Trois précisions de copie, relevées à la revue de la tâche 2 :**

- Sur un écart accepté, la ligne écrite porte le pays de la liste **et la langue du site** : le format Mangools ne transporte aucune langue. Dis-le en entier — « importés sur US, dans la langue du site (fr) » — sinon l'utilisateur découvrira le couple plus tard, et c'est exactement celui que ce projet a corrigé deux fois.
- Un site **sans aucun pays cible** produit lui aussi un verdict d'écart, avec une liste de cibles vide. Distingue les deux libellés : « votre site cible FR, cette liste est en US » ne veut pas dire la même chose que « votre site ne déclare aucun pays cible ». Sans ça, l'avertissement s'affiche sur chaque liste sans qu'on comprenne pourquoi.
- Une liste mélangeant plusieurs pays n'importe **rien**. Dis-le clairement, avec les pays trouvés, et indique le geste : nettoyer la liste chez Mangools, ou n'en cocher qu'une autre.

**Règles héritées des tâches précédentes, à respecter :**

- La sortie de l'étape reste en `outline`, jamais `ghost`, rendue dans tous les états et jamais désactivée.
- **Ne recharge que si l'import a réellement inséré au moins une ligne.** Un import à zéro ligne rechargerait sur la même étape et détruirait son propre message — défaut déjà corrigé sur l'import CSV. **Mais attention à ce que « zéro » veut dire** : la tâche 3 compte les mots-clés *créés*, donc un ré-import légitime rend zéro tout en ayant rafraîchi métriques et courbes. Distingue les deux à l'écran — « rien de nouveau, tout était déjà là » n'est pas « rien n'a été importé » — sinon un utilisateur qui recoche une liste déjà importée croira à un échec.
- Aucune écriture du cookie de report côté client : il est `httpOnly`.
- Les décisions vivent dans le module de logique, testé à part.

- [ ] **Step 1: Écrire les tests de logique** — sélection sans thème, thème pré-rempli, verdict affiché, import à zéro ligne qui n'avance pas l'étape.
- [ ] **Step 2: Vérifier le rouge.**
- [ ] **Step 3: Écrire le composant et les clés i18n** (les deux catalogues).
- [ ] **Step 4: Vérifier.** Run: `bun run test src/components/onboarding/ src/i18n && bunx tsc --noEmit && bun run build`
- [ ] **Step 5: Commit.**

---

### Task 6 : Portes de sortie

- [ ] **Step 1: Référence de tests.** Établis la liste des échecs sur `pre-dev` (worktree neuf, `bun install`, `bun run test:all`), puis compare avec `diff` à celle de la branche. **Reviens dans le dépôt principal avant de mesurer la branche** — lancer un agent en worktree déplace le répertoire courant du shell.
- [ ] **Step 2: `bunx tsc --noEmit`** à zéro et **`bun run build`** vert. C'est le build qui attrape les clés de traduction manquantes.
- [ ] **Step 3: `bunx biome check`** sur les fichiers touchés par la branche uniquement.
- [ ] **Step 4: `bunx knip`** — aucun nouvel export ou fichier inutilisé.
- [ ] **Step 5: Backstop de tenancy** — `bun run test src/lib/security/`. Déclare les nouveaux fichiers `"use server"` si nécessaire.
- [ ] **Step 6: Nettoyer le worktree de référence.**

## Ce que ce plan ne fait pas

- **`GET /kwfinder/suggested-keywords`** — des suggestions à partir de la seule URL du site. C'est la vraie réponse à la page blanche d'un site sans aucun mot-clé, et elle mérite son propre chantier.
- **`GET /kwfinder/limits`** — avertir avant un import volumineux plutôt qu'après un refus.
- **Le repli « US » de `related-actions.ts:106`** — le même défaut que celui corrigé ici, sur le chemin voisin. À traiter séparément pour ne pas mélanger deux périmètres.
- **SE-Ranking dans le parcours** — reste écarté : son accès est métré en crédits.
