# Wizard fusionné — machine à états unique — 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:** Remplacer les trois systèmes d'onboarding concurrents par une machine à états unique, dérivée de la base, dont le wizard est la vue plein écran et la checklist la vue reprise — et faire épouser au wizard le chemin critique réel (site → mots-clés → enrichissement → ciblage pays).

**Architecture:** Le catalogue d'étapes et la dérivation deviennent la seule source de vérité. `onboardingCompletedAt` cesse de signifier « prêt » et ne signale plus que « a vu le wizard ». La dérivation remonte dans le RSC de la sidebar. Le wizard consomme les mêmes étapes que la checklist, donc les deux ne peuvent plus diverger.

**Tech Stack:** Next 16 App Router, React 19, Drizzle/Postgres, Better Auth, next-intl, shadcn/ui, bun, biome.

**Branche :** `feature/onboarding-wizard-tutorial` (déjà créée depuis `dev`, porte déjà la spec et les plans).

**Spec de référence :** `docs/superpowers/specs/2026-07-25-onboarding-wizard-tutorial-design.md`.

**Prérequis :** le plan `2026-07-25-onboarding-dead-ends.md` doit être mergé dans `dev` d'abord — la Task 5 dépend de `resolveOnboardingOrgId`.

## Global Constraints

- **Package manager : bun.** Jamais `npm`/`npx`. CLI shadcn : `bunx --bun shadcn@latest add <composant>`.
- **Aucun import direct de `radix-ui` dans le nouveau code.** Uniquement `src/components/ui/*`.
- **`@onboardjs/*` ne doit être importé nulle part** une fois la Task 9 faite. En attendant, seul `src/lib/onboarding/activation/engine.tsx` y est autorisé.
- **i18n : `messages/en.json` ET `messages/fr.json`**, mêmes clés, même arborescence. Seules ces deux locales existent. Les étapes utilisent le schéma imbriqué `Activation.steps.<stepId>.title|description|cta`.
- **Tests DB** : `describe.skipIf(!RUN_DB)`, `mock.module("server-only", () => ({}))` en tête, imports dynamiques dans `beforeEach`, `await assertTestDb()` avant tout `delete`, jamais de `db.end()`.
- **Préfixe unique de fixtures par fichier de test** pour éviter les collisions de PK entre fichiers d'un même répertoire (ils partagent un process bun).
- **`stages` est un `text[]`** : le seul opérateur autorisé est `sql\`${keywords.stages} @> ARRAY['x']::text[]\`` (index GIN). Jamais de `= ANY` ni de filtrage JS.
- **`volume_primary` est `GENERATED ALWAYS … STORED`** : lecture seule, jamais dans un `.values()` ni un `.set()`.
- **`content_items` et `keyword_targets` n'ont pas de colonne `org_id`** : toute dérivation org-scopée passe par `innerJoin(sites, …)` + `eq(sites.orgId, orgId)`.
- **`getOrgQuotaLimits` se résout AVANT `db.transaction`** (sinon deadlock : documenté dans `quotas.ts`).
- **Conventional commits.** Jamais de commit sur `main`.

---

### Task 1 : Nouveau catalogue d'étapes

Les cinq étapes actuelles (`add-site`, `connect-gsc`, `import-keywords`, `first-enrichment`, `invite-teammate`) sont remplacées par six étapes dont quatre sur le chemin critique. Les tests existants codent en dur l'ancienne liste : ils sont réécrits dans la même tâche.

**Files:**
- Modify: `src/lib/onboarding/activation/types.ts`
- Modify: `src/lib/onboarding/activation/steps.ts`
- Modify: `src/lib/onboarding/activation/steps.test.ts`

**Interfaces:**
- Consumes: rien.
- Produces: `ACTIVATION_STEP_IDS`, `ActivationStepId`, `ActivationFlags`, `ActivationStepDef`, `ACTIVATION_STEPS`, `requiredComplete`, `doneCount`, `firstIncompleteStep`, `WIZARD_STEP_IDS`, `WizardStepId`.

- [ ] **Step 1: Réécrire le test des étapes**

Remplacer intégralement `src/lib/onboarding/activation/steps.test.ts` :

```ts
import { describe, expect, it } from "bun:test";
import {
  ACTIVATION_STEPS,
  doneCount,
  firstIncompleteStep,
  requiredComplete,
  WIZARD_STEP_IDS,
} from "./steps";
import { ACTIVATION_STEP_IDS, type ActivationFlags } from "./types";

const none: ActivationFlags = {
  "create-site": false,
  "add-keywords": false,
  "enrich-keywords": false,
  "target-country": false,
  "connect-gsc": false,
  "invite-teammate": false,
};
const all: ActivationFlags = {
  "create-site": true,
  "add-keywords": true,
  "enrich-keywords": true,
  "target-country": true,
  "connect-gsc": true,
  "invite-teammate": true,
};

describe("ACTIVATION_STEPS", () => {
  it("expose les six étapes dans l'ordre canonique", () => {
    expect(ACTIVATION_STEPS.map((s) => s.id)).toEqual([
      "create-site",
      "add-keywords",
      "enrich-keywords",
      "target-country",
      "connect-gsc",
      "invite-teammate",
    ]);
  });

  it("couvre exactement ACTIVATION_STEP_IDS", () => {
    expect(ACTIVATION_STEPS.map((s) => s.id).sort()).toEqual(
      [...ACTIVATION_STEP_IDS].sort(),
    );
  });

  it("déclare requises les quatre étapes du chemin critique", () => {
    expect(ACTIVATION_STEPS.filter((s) => s.required).map((s) => s.id)).toEqual([
      "create-site",
      "add-keywords",
      "target-country",
    ]);
  });

  it("n'envoie jamais un CTA vers une section gatée en production", () => {
    for (const step of ACTIVATION_STEPS) {
      expect(step.href.startsWith("/content")).toBe(false);
      expect(step.href).not.toBe("/backlink/overview");
    }
  });
});

describe("WIZARD_STEP_IDS", () => {
  it("est le chemin critique, enrichissement compris, dans l'ordre du wizard", () => {
    expect(WIZARD_STEP_IDS).toEqual([
      "create-site",
      "add-keywords",
      "enrich-keywords",
      "target-country",
    ]);
  });
});

describe("helpers", () => {
  it("requiredComplete est faux tant qu'une étape requise manque", () => {
    expect(requiredComplete(none)).toBe(false);
    expect(requiredComplete({ ...all, "target-country": false })).toBe(false);
  });

  it("requiredComplete ignore les étapes non requises", () => {
    expect(
      requiredComplete({
        ...all,
        "enrich-keywords": false,
        "connect-gsc": false,
        "invite-teammate": false,
      }),
    ).toBe(true);
  });

  it("doneCount compte toutes les étapes, requises ou non", () => {
    expect(doneCount(none)).toBe(0);
    expect(doneCount(all)).toBe(6);
  });

  it("firstIncompleteStep suit l'ordre canonique", () => {
    expect(firstIncompleteStep(none)).toBe("create-site");
    expect(firstIncompleteStep({ ...none, "create-site": true })).toBe("add-keywords");
    expect(firstIncompleteStep(all)).toBeNull();
  });
});
```

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

Run: `bun run test src/lib/onboarding/activation/steps.test.ts`
Expected: FAIL — `WIZARD_STEP_IDS` n'existe pas, et les ids d'étapes ne correspondent pas.

- [ ] **Step 3: Réécrire les types**

Remplacer le haut de `src/lib/onboarding/activation/types.ts` (garder `ActivationSnapshot` tel quel) :

```ts
/**
 * Étapes d'activation — source de vérité unique du parcours d'entrée.
 * Les quatre premières forment le chemin critique ; `enrich-keywords` y figure
 * (le wizard la propose) sans être requise pour considérer l'activation faite.
 */
export const ACTIVATION_STEP_IDS = [
  "create-site",
  "add-keywords",
  "enrich-keywords",
  "target-country",
  "connect-gsc",
  "invite-teammate",
] as const;

export type ActivationStepId = (typeof ACTIVATION_STEP_IDS)[number];

export type ActivationFlags = Record<ActivationStepId, boolean>;

export type ActivationStepDef = {
  id: ActivationStepId;
  /** Requise pour que l'activation soit considérée faite (et le wizard clos). */
  required: boolean;
  /** Cible de navigation du CTA de la checklist. Jamais une route gatée en prod. */
  href: string;
};
```

- [ ] **Step 4: Réécrire le catalogue**

Remplacer `src/lib/onboarding/activation/steps.ts` :

```ts
import {
  ACTIVATION_STEP_IDS,
  type ActivationFlags,
  type ActivationStepDef,
  type ActivationStepId,
} from "./types";

export const ACTIVATION_STEPS: readonly ActivationStepDef[] = [
  { id: "create-site", required: true, href: "/projects" },
  { id: "add-keywords", required: true, href: "/keywords/reservoir" },
  { id: "enrich-keywords", required: false, href: "/keywords/reservoir" },
  { id: "target-country", required: true, href: "/keywords/recherche" },
  { id: "connect-gsc", required: false, href: "/gsc" },
  { id: "invite-teammate", required: false, href: "/settings/team" },
];

/** Étapes portées par le wizard plein écran, dans son ordre d'affichage. */
export const WIZARD_STEP_IDS = [
  "create-site",
  "add-keywords",
  "enrich-keywords",
  "target-country",
] as const satisfies readonly ActivationStepId[];

export type WizardStepId = (typeof WIZARD_STEP_IDS)[number];

export function requiredComplete(flags: ActivationFlags): boolean {
  return ACTIVATION_STEPS.every((s) => !s.required || flags[s.id]);
}

export function doneCount(flags: ActivationFlags): number {
  return ACTIVATION_STEP_IDS.filter((id) => flags[id]).length;
}

export function firstIncompleteStep(flags: ActivationFlags): ActivationStepId | null {
  return ACTIVATION_STEPS.find((s) => !flags[s.id])?.id ?? null;
}
```

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

Run: `bun run test src/lib/onboarding/activation/steps.test.ts`
Expected: PASS.

Le typage casse ailleurs (dérivation, UI, moteur) : c'est attendu, les tâches suivantes le résolvent. Ne pas commiter tant que la Task 2 n'est pas faite.

---

### Task 2 : Dérivation des six flags

La dérivation actuelle ignore le ciblage pays et mesure l'enrichissement avec la mauvaise colonne (`enrichment_exports` trace les *exports*, pas l'enrichissement).

**Files:**
- Modify: `src/lib/onboarding/activation/derive.ts`
- Modify: `src/lib/onboarding/activation/derive.test.ts`

**Interfaces:**
- Consumes: `ActivationFlags` (Task 1).
- Produces: `deriveActivationFlags(orgId: string): Promise<ActivationFlags>` — signature inchangée, contenu remplacé.

- [ ] **Step 1: Écrire les tests des deux nouveaux flags**

Dans `src/lib/onboarding/activation/derive.test.ts`, conserver la structure existante (préfixe de fixtures `act-derive-`, `beforeEach` avec imports dynamiques et `assertTestDb`) et remplacer le corps des cas par :

```ts
  it("create-site est vrai dès qu'un site existe", async () => {
    expect((await deriveActivationFlags(ORG))["create-site"]).toBe(true);
  });

  it("add-keywords est vrai dès qu'un mot-clé existe sur un site de l'org", async () => {
    expect((await deriveActivationFlags(ORG))["add-keywords"]).toBe(false);
    await db.insert(domain.keywords).values({
      id: `${SITE}-alpha`,
      siteId: SITE,
      term: "alpha",
      slug: "alpha",
      primarySource: "manual",
    });
    expect((await deriveActivationFlags(ORG))["add-keywords"]).toBe(true);
  });

  it("enrich-keywords suit volume_primary, pas les exports", async () => {
    // Un mot-clé sans métrique : la colonne générée reste NULL.
    await db.insert(domain.keywords).values({
      id: `${SITE}-nometric`,
      siteId: SITE,
      term: "nometric",
      slug: "nometric",
      primarySource: "manual",
    });
    expect((await deriveActivationFlags(ORG))["enrich-keywords"]).toBe(false);

    // Un export d'enrichissement ne doit PAS suffire (l'ancienne dérivation).
    await db.insert(domain.keywords).values({
      id: `${SITE}-exported`,
      siteId: SITE,
      term: "exported",
      slug: "exported",
      primarySource: "manual",
      enrichmentExports: { mangools: new Date().toISOString() },
    });
    expect((await deriveActivationFlags(ORG))["enrich-keywords"]).toBe(false);

    // Une métrique de la source primaire alimente volume_primary.
    await db.insert(domain.keywords).values({
      id: `${SITE}-enriched`,
      siteId: SITE,
      term: "enriched",
      slug: "enriched",
      primarySource: "mangools",
      metrics: { mangools: { volume: 1200 } },
    });
    expect((await deriveActivationFlags(ORG))["enrich-keywords"]).toBe(true);
  });

  it("target-country exige un keyword_target au statut planned", async () => {
    await db.insert(domain.keywords).values({
      id: `${SITE}-target`,
      siteId: SITE,
      term: "target",
      slug: "target",
      primarySource: "manual",
    });
    await db.insert(domain.keywordTargets).values({
      keywordId: `${SITE}-target`,
      siteId: SITE,
      country: "FR",
      status: "candidate",
    });
    expect((await deriveActivationFlags(ORG))["target-country"]).toBe(false);

    const { and, eq } = await import("drizzle-orm");
    await db
      .update(domain.keywordTargets)
      .set({ status: "planned" })
      .where(
        and(
          eq(domain.keywordTargets.keywordId, `${SITE}-target`),
          eq(domain.keywordTargets.country, "FR"),
        ),
      );
    expect((await deriveActivationFlags(ORG))["target-country"]).toBe(true);
  });

  it("connect-gsc suit gscProperty", async () => {
    expect((await deriveActivationFlags(ORG))["connect-gsc"]).toBe(false);
    const { eq } = await import("drizzle-orm");
    await db
      .update(domain.sites)
      .set({ gscProperty: "sc-domain:act-derive.test" })
      .where(eq(domain.sites.id, SITE));
    expect((await deriveActivationFlags(ORG))["connect-gsc"]).toBe(true);
  });

  it("ne fuit pas entre organisations", async () => {
    // Un mot-clé d'une AUTRE org ne doit pas cocher add-keywords ici.
    expect((await deriveActivationFlags(OTHER_ORG))["add-keywords"]).toBe(false);
  });
```

Conserver le test d'étanchéité inter-org existant et ses fixtures `OTHER_ORG`.

- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**

Run: `bun run db:migrate:test && bun run test src/lib/onboarding/activation/derive.test.ts`
Expected: FAIL — les clés `create-site`, `add-keywords`, `enrich-keywords`, `target-country` n'existent pas dans le retour.

- [ ] **Step 3: Réécrire la dérivation**

Remplacer `src/lib/onboarding/activation/derive.ts` :

```ts
import "server-only";
import { and, eq, isNotNull, ne, sql } from "drizzle-orm";
import { invitation, member } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { keywords, keywordTargets, sites } from "@/lib/db/domain-schema";
import type { ActivationFlags } from "./types";

async function exists(query: PromiseLike<unknown[]>): Promise<boolean> {
  return (await query).length > 0;
}

/**
 * Dérive l'état d'activation depuis l'état produit réel. Rien n'est stocké :
 * c'est ce qui rend l'état « terminé mais vide » impossible à représenter.
 */
export async function deriveActivationFlags(orgId: string): Promise<ActivationFlags> {
  const one = { one: sql<number>`1` };

  const [hasSite, hasKeyword, hasEnrichment, hasTarget, hasGsc, hasTeammate] =
    await Promise.all([
      exists(db.select(one).from(sites).where(eq(sites.orgId, orgId)).limit(1)),

      exists(
        db
          .select(one)
          .from(keywords)
          .innerJoin(sites, eq(keywords.siteId, sites.id))
          .where(eq(sites.orgId, orgId))
          .limit(1),
      ),

      // volume_primary est GENERATED depuis metrics->primary_source : il mesure
      // l'enrichissement réel, contrairement à enrichment_exports qui ne trace
      // que les exports vers un outil tiers.
      exists(
        db
          .select(one)
          .from(keywords)
          .innerJoin(sites, eq(keywords.siteId, sites.id))
          .where(and(eq(sites.orgId, orgId), isNotNull(keywords.volumePrimary)))
          .limit(1),
      ),

      // Seul le statut `planned` rend /keywords/planning peuplé et Dispatch
      // atteignable ; `candidate` ne suffit pas.
      exists(
        db
          .select(one)
          .from(keywordTargets)
          .innerJoin(sites, eq(keywordTargets.siteId, sites.id))
          .where(and(eq(sites.orgId, orgId), eq(keywordTargets.status, "planned")))
          .limit(1),
      ),

      exists(
        db
          .select(one)
          .from(sites)
          .where(and(eq(sites.orgId, orgId), isNotNull(sites.gscProperty)))
          .limit(1),
      ),

      (async () =>
        (await exists(
          db
            .select(one)
            .from(member)
            .where(and(eq(member.organizationId, orgId), ne(member.role, "owner")))
            .limit(1),
        )) ||
        (await exists(
          db
            .select(one)
            .from(invitation)
            .where(
              and(
                eq(invitation.organizationId, orgId),
                eq(invitation.status, "pending"),
              ),
            )
            .limit(1),
        )))(),
    ]);

  return {
    "create-site": hasSite,
    "add-keywords": hasKeyword,
    "enrich-keywords": hasEnrichment,
    "target-country": hasTarget,
    "connect-gsc": hasGsc,
    "invite-teammate": hasTeammate,
  };
}
```

- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**

Run: `bun run test src/lib/onboarding/activation/derive.test.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/lib/onboarding/activation/types.ts src/lib/onboarding/activation/steps.ts src/lib/onboarding/activation/steps.test.ts src/lib/onboarding/activation/derive.ts src/lib/onboarding/activation/derive.test.ts
git commit -m "feat(activation): catalogue d'etapes et derivation du chemin critique

Six etapes remplacent les cinq precedentes. Le ciblage pays entre dans
la derivation, et l'enrichissement suit volume_primary au lieu de
enrichment_exports qui ne tracait que les exports."
```

---

### Task 3 : Remonter la dérivation dans le RSC de la sidebar

`ActivationCard` refetch au montage, à chaque changement de `pathname`, à chaque changement de site et sur `window.focus` — soit six requêtes `EXISTS` par navigation, pour un composant qui disparaît une fois l'activation faite.

**Note ajoutée en cours d'exécution (2026-07-27)** — le renommage des clés i18n `Activation.steps` est **déplacé de la tâche 9 vers celle-ci**. Motif : `activation-panel.tsx` lit ces clés par identifiant d'étape ; les laisser sur les anciens identifiants maintenait la branche non compilable de la tâche 1 à la tâche 8, pour un travail qui appartient au même fichier que le reste de cette tâche. Voir Step 0 ci-dessous.

**Files:**
- Create: `src/lib/onboarding/activation/snapshot.ts`
- Create: `src/lib/onboarding/activation/snapshot.test.ts`
- Modify: `src/components/onboarding/activation-card.tsx`
- Modify: `src/components/onboarding/activation-panel.tsx`
- Modify: `src/components/team-switcher.tsx`
- Modify: `src/components/app-sidebar.tsx`
- Modify: `src/app/(app)/layout.tsx`
- Modify: `src/lib/onboarding/activation/actions.ts`
- Modify: `src/lib/onboarding/activation/actions.test.ts`
- Modify: `messages/en.json`, `messages/fr.json`

- [ ] **Step 0: Renommer les clés i18n des étapes**

Dans `messages/en.json` et `messages/fr.json`, namespace `Activation.steps`, remplacer les cinq anciens identifiants (`add-site`, `connect-gsc`, `import-keywords`, `first-enrichment`, `invite-teammate`) par les six nouveaux : `create-site`, `add-keywords`, `enrich-keywords`, `target-country`, `connect-gsc`, `invite-teammate` — chacun avec `title`, `description` et `cta`.

Seules ces deux locales existent. Les deux fichiers doivent porter exactement les mêmes clés et la même arborescence.

Reprends les libellés existants quand l'étape survit (`connect-gsc`, `invite-teammate`) et écris les nouveaux dans le style des voisins : titre à l'impératif, description d'une phrase disant ce que l'étape débloque, `cta` court. `target-country` est l'étape « ton premier plan » de la spec — cible un pays sur un mot-clé et génère le brief.

Après ce renommage, `activation-panel.tsx` doit de nouveau typer : c'est le signal que l'étape est complète.

**Interfaces:**
- Consumes: `deriveActivationFlags` (Task 2), `isActivationDismissed` (`dismiss.ts`, inchangé).
- Produces: `getActivationSnapshot(): Promise<ActivationSnapshot | null>` — appelée par le layout `(app)` uniquement.

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

Créer `src/lib/onboarding/activation/snapshot.test.ts` :

```ts
import { describe, expect, it, mock } from "bun:test";

mock.module("server-only", () => ({}));

describe("getActivationSnapshot", () => {
  it("retourne null quand la dérivation échoue, sans propager l'erreur", async () => {
    mock.module("@/lib/active-org", () => ({
      getActiveOrgId: async () => "org_x",
      getActiveUserId: async () => "user_x",
    }));
    mock.module("./derive", () => ({
      deriveActivationFlags: async () => {
        throw new Error("colonne absente");
      },
    }));
    mock.module("./dismiss", () => ({ isActivationDismissed: async () => false }));

    const { getActivationSnapshot } = await import("./snapshot");
    expect(await getActivationSnapshot()).toBeNull();
  });
});
```

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

Run: `bun run test src/lib/onboarding/activation/snapshot.test.ts`
Expected: FAIL — `Cannot find module './snapshot'`.

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

Créer `src/lib/onboarding/activation/snapshot.ts` :

```ts
import "server-only";
import { cache } from "react";
import { getActiveOrgId, getActiveUserId } from "@/lib/active-org";
import { deriveActivationFlags } from "./derive";
import { isActivationDismissed } from "./dismiss";
import type { ActivationSnapshot } from "./types";

/**
 * Lecture serveur de l'état d'activation, dédupée par requête.
 * Fail-silent : la checklist disparaît plutôt que de casser le rendu de l'app.
 * En cas de disparition inattendue, chercher `[activation] snapshot failed:`.
 */
export const getActivationSnapshot = cache(
  async (): Promise<ActivationSnapshot | null> => {
    try {
      const [orgId, userId] = await Promise.all([getActiveOrgId(), getActiveUserId()]);
      const [flags, dismissed] = await Promise.all([
        deriveActivationFlags(orgId),
        isActivationDismissed(userId, orgId),
      ]);
      return { flags, dismissed };
    } catch (e) {
      console.error("[activation] snapshot failed:", e);
      return null;
    }
  },
);
```

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

Run: `bun run test src/lib/onboarding/activation/snapshot.test.ts`
Expected: PASS.

- [ ] **Step 5: Faire descendre le snapshot en props**

Dans `src/app/(app)/layout.tsx`, ajouter l'import et l'appel **après** le gate de facturation (l'appel dépend de `getActiveOrgId`, donc il doit venir après la résolution d'org) :

```ts
import { getActivationSnapshot } from "@/lib/onboarding/activation/snapshot";
```

```tsx
  const activation = await getActivationSnapshot();
```

puis passer `activation={activation}` à `<AppSidebar … />`.

Dans `src/components/app-sidebar.tsx`, ajouter la prop au type et la transmettre à `TeamSwitcher` :

```ts
  activation?: ActivationSnapshot | null;
```

Dans `src/components/team-switcher.tsx`, transmettre la même prop à `<ActivationCard activation={activation} />`.

- [ ] **Step 6: Rendre `ActivationCard` piloté par ses props**

Dans `src/components/onboarding/activation-card.tsx`, supprimer intégralement l'état de fetch (`useState` des flags, `useEffect` sur `pathname`/`activeSiteId`/`sites.length`/`window.focus`, `requestIdRef`, l'appel à `getActivationAction`) et faire du composant une fonction de ses props :

```tsx
"use client";

import { useState } from "react";
import { ActivationPanel } from "@/components/onboarding/activation-panel";
import { dismissActivationAction } from "@/lib/onboarding/activation/actions";
import { firstIncompleteStep, requiredComplete } from "@/lib/onboarding/activation/steps";
import type { ActivationSnapshot } from "@/lib/onboarding/activation/types";

export function ActivationCard({
  activation,
}: {
  activation: ActivationSnapshot | null;
}) {
  // Dismiss optimiste : le serveur re-rendra sans la carte au prochain passage.
  const [dismissed, setDismissed] = useState(false);

  const flags = activation?.flags ?? null;
  if (!flags || activation?.dismissed || dismissed || requiredComplete(flags)) {
    return null;
  }

  const suggested = firstIncompleteStep(flags);

  return (
    <ActivationPanel
      flags={flags}
      suggestedStepId={suggested}
      onDismiss={() => {
        setDismissed(true);
        void dismissActivationAction();
      }}
    />
  );
}
```

Conserver la classe `group-data-[collapsible=icon]:hidden` sur le conteneur si elle existait, et adapter `ActivationPanel` si sa signature diffère — ne pas changer son rendu.

- [ ] **Step 7: Retirer l'action de lecture devenue inutile**

Dans `src/lib/onboarding/activation/actions.ts`, supprimer `getActivationAction` (la lecture passe désormais par le RSC). Mettre à jour le test de surface dans `actions.test.ts` :

```ts
    const mod = await import("./actions");
    expect(Object.keys(mod).sort()).toEqual([
      "dismissActivationAction",
      "restoreActivationAction",
    ]);
```

- [ ] **Step 8: Vérifier**

Run: `bun run test src/lib/onboarding && bunx --bun tsc --noEmit`
Expected: PASS, aucune erreur de type.

- [ ] **Step 9: Commit**

```bash
git add src/lib/onboarding/activation/snapshot.ts src/lib/onboarding/activation/snapshot.test.ts src/lib/onboarding/activation/actions.ts src/lib/onboarding/activation/actions.test.ts src/components/onboarding/activation-card.tsx src/components/team-switcher.tsx src/components/app-sidebar.tsx "src/app/(app)/layout.tsx"
git commit -m "perf(activation): deriver dans le RSC au lieu du client

La carte refetchait a chaque navigation et a chaque focus de fenetre,
soit six EXISTS par navigation pour un composant qui disparait une fois
l'activation faite."
```

---

### Task 4 : Le gate du wizard devient dérivé

`onboardingCompletedAt` cesse d'être le critère de « prêt ». Le wizard s'impose tant que les étapes requises manquent, sauf si l'utilisateur a explicitement différé pendant cette session.

**Files:**
- Create: `src/lib/onboarding/wizard-gate.ts`
- Create: `src/lib/onboarding/wizard-gate.test.ts`
- Modify: `src/app/(app)/layout.tsx`
- Modify: `src/app/onboarding/page.tsx`

**Interfaces:**
- Consumes: `requiredComplete` (Task 1), `getActivationSnapshot` (Task 3).
- Produces: `shouldEnterWizard(input: WizardGateInput): boolean`, `DEFER_COOKIE`.

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

Créer `src/lib/onboarding/wizard-gate.test.ts` :

```ts
import { describe, expect, it } from "bun:test";
import { shouldEnterWizard } from "./wizard-gate";

const complete = { "create-site": true, "add-keywords": true, "target-country": true };
const incomplete = { "create-site": false, "add-keywords": false, "target-country": false };

describe("shouldEnterWizard", () => {
  it("envoie au wizard quand les étapes requises manquent", () => {
    expect(
      shouldEnterWizard({ requiredDone: false, deferred: false, canCreateSite: true }),
    ).toBe(true);
  });

  it("n'y envoie pas quand les étapes requises sont faites", () => {
    expect(
      shouldEnterWizard({ requiredDone: true, deferred: false, canCreateSite: true }),
    ).toBe(false);
  });

  it("n'y envoie pas quand l'utilisateur a différé", () => {
    expect(
      shouldEnterWizard({ requiredDone: false, deferred: true, canCreateSite: true }),
    ).toBe(false);
  });

  it("n'y envoie jamais un membre qui ne peut pas créer de site", () => {
    expect(
      shouldEnterWizard({ requiredDone: false, deferred: false, canCreateSite: false }),
    ).toBe(false);
  });

  void complete;
  void incomplete;
});
```

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

Run: `bun run test src/lib/onboarding/wizard-gate.test.ts`
Expected: FAIL — `Cannot find module './wizard-gate'`.

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

Créer `src/lib/onboarding/wizard-gate.ts` :

```ts
/**
 * Décide si l'utilisateur doit être envoyé au wizard.
 * Pur : la lecture de l'état vit dans le layout, pas ici.
 */

/** Cookie de report, posé côté client, durée de session. */
export const DEFER_COOKIE = "onboarding-deferred";

export type WizardGateInput = {
  /** requiredComplete(flags) — dérivé de la base. */
  requiredDone: boolean;
  /** L'utilisateur a cliqué « je le ferai plus tard ». */
  deferred: boolean;
  /** owner/admin. Un membre ne peut pas créer de site : le wizard n'a rien à lui proposer. */
  canCreateSite: boolean;
};

export function shouldEnterWizard(input: WizardGateInput): boolean {
  if (!input.canCreateSite) return false;
  if (input.deferred) return false;
  return !input.requiredDone;
}
```

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

Run: `bun run test src/lib/onboarding/wizard-gate.test.ts`
Expected: PASS — 4 tests.

- [ ] **Step 5: Brancher le gate**

Dans `src/app/(app)/layout.tsx`, **remplacer** la ligne :

```tsx
  if (await hasPendingOnboarding(session.user.id)) redirect("/onboarding");
```

Ce gate lisait `onboardingCompletedAt` avant toute résolution d'org. Le nouveau gate a besoin de l'org : il descend donc **après** la résolution org/sites et le gate de facturation, juste avant le calcul de `activation` :

```tsx
  const activation = await getActivationSnapshot();
  const deferred = (await cookies()).get(DEFER_COOKIE)?.value === "1";
  if (
    activation &&
    shouldEnterWizard({
      requiredDone: requiredComplete(activation.flags),
      deferred,
      canCreateSite: fullAccess,
    })
  ) {
    redirect("/onboarding");
  }
```

Réutiliser la constante `fullAccess` introduite par la Task 5 du plan des correctifs. Importer `cookies` depuis `next/headers`, `DEFER_COOKIE` et `shouldEnterWizard` depuis `@/lib/onboarding/wizard-gate`, `requiredComplete` depuis `@/lib/onboarding/activation/steps`.

Conserver `hasPendingOnboarding` **uniquement** pour décider d'afficher l'écran de bienvenue de la première étape ; ne plus l'utiliser comme gate.

- [ ] **Step 6: Inverser le gate côté `/onboarding`**

Dans `src/app/onboarding/page.tsx`, remplacer `if (!(await hasPendingOnboarding(userId))) redirect("/keywords/reservoir")` par une sortie fondée sur le même état dérivé :

```tsx
  const snapshot = await getActivationSnapshot();
  if (snapshot && requiredComplete(snapshot.flags)) {
    redirect("/keywords/planning");
  }
```

Un utilisateur dont tout est fait ne peut plus rentrer dans le wizard, et l'atterrissage est désormais le Planning — le premier écran qui montre un plan de travail.

- [ ] **Step 7: Vérifier**

Run: `bun run test src/lib/onboarding && bunx --bun tsc --noEmit`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add src/lib/onboarding/wizard-gate.ts src/lib/onboarding/wizard-gate.test.ts "src/app/(app)/layout.tsx" src/app/onboarding/page.tsx
git commit -m "feat(onboarding): gate du wizard derive de l'etat produit

onboardingCompletedAt ne signifie plus 'pret' : le wizard s'impose tant
que les etapes requises manquent. L'etat 'termine mais vide' devient
impossible a representer."
```

---

### Task 5 : Étape 1 du wizard — créer le site

L'étape la plus critique du produit, aujourd'hui absente du wizard. Deux obstacles techniques identifiés en reconnaissance : `/onboarding` n'a pas de `SiteProvider` (donc `useActiveSite()` y throw), et `hasFullSiteAccess()` lit l'org de session sans self-heal, si bien que `createSiteAction` peut throw `Forbidden` sur une session fraîche.

**Files:**
- Create: `src/components/onboarding/steps/site-step.tsx`
- Modify: `src/components/onboarding/onboarding-wizard.tsx`
- Modify: `src/app/onboarding/page.tsx`
- Modify: `messages/en.json`, `messages/fr.json`

**Interfaces:**
- Consumes: `SiteForm` (`src/components/site/site-form.tsx`), `createSiteAction`, `writeActiveSiteCookie` (`@/lib/sites/active-site-client`), `QuotaNotice`.
- Produces: `SiteStep({ onCreated }: { onCreated: () => void })`.

- [ ] **Step 1: Neutraliser la fenêtre `Forbidden`**

Dans `src/app/onboarding/page.tsx`, forcer la résolution d'org **avant** de rendre le wizard, ce qui déclenche le self-heal de `getActiveOrgId` et pose l'organisation active :

```tsx
  // Force le self-heal de la session : sans lui, hasFullSiteAccess() lit une
  // session sans org active et createSiteAction throw "Forbidden".
  const activeOrgId = await resolveOnboardingOrgId(
    session.user.id,
    session.session.activeOrganizationId,
  );
  if (!activeOrgId) redirect("/login");
```

`resolveOnboardingOrgId` vient du plan des correctifs (Task 3). Si l'organisation reste introuvable, l'utilisateur n'a rien à faire dans le wizard.

- [ ] **Step 2: Écrire l'étape**

Créer `src/components/onboarding/steps/site-step.tsx` :

```tsx
"use client";

import { useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { QuotaNotice } from "@/components/billing/quota-notice";
import { SiteForm } from "@/components/site/site-form";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import type { QuotaExceeded } from "@/lib/billing/quotas";
import { writeActiveSiteCookie } from "@/lib/sites/active-site-client";
import { createSiteAction } from "@/lib/sites/actions";
import type { SiteInput } from "@/lib/sites/schema";

export function SiteStep({ onCreated }: { onCreated: () => void }) {
  const t = useTranslations("Onboarding");
  const [pending, startTransition] = useTransition();
  const [error, setError] = useState<string | null>(null);
  const [quota, setQuota] = useState<QuotaExceeded | null>(null);

  function submit(input: SiteInput) {
    setError(null);
    setQuota(null);
    startTransition(async () => {
      // Le fan-out backlinks insère ~380 lignes : la latence est normale.
      const res = await createSiteAction(input);
      if (!res.ok) {
        if (res.quota) setQuota(res.quota);
        else setError(res.error);
        return;
      }
      // /onboarding est hors du groupe (app) : pas de SiteProvider, donc pas
      // de addSite(). On pose le cookie de site actif à la main.
      writeActiveSiteCookie(res.site.id);
      onCreated();
    });
  }

  return (
    <Card className="w-full max-w-lg">
      <CardHeader>
        <CardTitle>{t("siteTitle")}</CardTitle>
        <CardDescription>{t("siteHelp")}</CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {quota ? <QuotaNotice quota={quota} /> : null}
        <SiteForm
          submitLabel={t("siteSubmit")}
          pending={pending}
          error={error}
          onSubmit={submit}
        />
      </CardContent>
    </Card>
  );
}
```

Aucun bouton « Passer » : c'est la seule étape réellement bloquante du parcours.

- [ ] **Step 3: Rendre le pays cible obligatoire**

Dans `src/components/site/site-form.tsx`, désactiver la soumission tant qu'aucun pays n'est choisi. Remplacer la condition `disabled` du bouton par :

```tsx
disabled={!name.trim() || !cleanDomain(domain) || countries.length === 0 || pending}
```

Le pays cible conditionne les mots-clés associés (`no_location`) et la ventilation GSC ; le laisser vide casse ces fonctions sans message.

- [ ] **Step 4: Ajouter les clés i18n**

`messages/en.json`, namespace `Onboarding` :

```json
    "siteTitle": "Add your site",
    "siteHelp": "Your keywords, pages and backlinks all attach to a site. Pick the target country carefully: it drives keyword suggestions and Search Console breakdowns.",
    "siteSubmit": "Create site"
```

`messages/fr.json`, même namespace :

```json
    "siteTitle": "Ajoute ton site",
    "siteHelp": "Tes mots-clés, tes pages et tes backlinks s'y rattachent tous. Choisis le pays cible avec soin : il pilote les suggestions de mots-clés et la ventilation Search Console.",
    "siteSubmit": "Créer le site"
```

- [ ] **Step 5: Vérifier**

Run: `bun run test src/i18n && bunx --bun tsc --noEmit`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/components/onboarding/steps/site-step.tsx src/components/site/site-form.tsx src/app/onboarding/page.tsx messages/en.json messages/fr.json
git commit -m "feat(onboarding): etape 1 du wizard, creation du site

Le pays cible devient obligatoire : il conditionne silencieusement les
mots-cles associes et la ventilation GSC. La page force le self-heal
d'organisation pour eviter le Forbidden de createSiteAction."
```

---

### Task 6 : Étape 2 du wizard — Search Console, jamais bloquante

**Tâche ajoutée le 2026-07-27**, sur décision utilisateur. Le parcours passe de quatre à cinq étapes : `site → Search Console → import → enrichissement → premier plan`. Search Console arrive juste après la création du site parce que c'est la seule source qui apporte des données réelles sans rien saisir — mais **un site neuf n'en a pas**, et l'étape ne doit jamais bloquer.

C'est le point le plus important de cette tâche : la sortie n'est pas une punition, c'est le chemin normal pour un site récent. La copie doit le dire.

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

**Interfaces:**
- Consumes: `getGscStatusAction`, `setSiteGscPropertyAction` (`src/lib/gsc/actions.ts`), `GscStatus` / `GscSiteRow` / `OwnedGscProperty` — livrés par la refonte de l'autorité GSC, déjà fusionnée.
- Produces: `GscStep`, monté par le wizard en position 2.

**Ce que l'écran doit faire**, dans l'ordre de ce que voit l'utilisateur :

1. **Si aucun compte Google n'est lié** (`status.myGoogle === "none"`) : proposer de lier, avec le flux `linkSocial` existant — celui de `gsc-connection-card.tsx`, ne le réécris pas. Sous le bouton, la sortie : « Mon site est trop récent pour avoir des données » en `outline`.
2. **Si un compte est lié sans le scope** (`"missing-scope"`) : proposer de reconnecter, même sortie disponible.
3. **Si un compte est lié avec le scope** (`"linked"`) : afficher le sélecteur de propriétés (`status.properties`) pour le site courant, et appeler `setSiteGscPropertyAction(siteId, property)` à la sélection. Une fois la propriété mappée, l'étape est franchie et le wizard avance.
4. **Si `status.propertiesError`** : afficher le message d'échec de vérification, ne pas prétendre que l'utilisateur n'a aucune propriété, et laisser la sortie disponible.

**Règles non négociables :**

- L'étape est **facultative** (`connect-gsc` est `required: false`) : passer ne laisse aucune dette bloquante, l'étape réapparaît simplement dans la checklist.
- « Passer cette étape » est un bouton `outline`, jamais `ghost` — règle d'ergonomie de la spec §5, en réponse directe à la rupture n°2.
- Pas de sélecteur de compte Google : l'utilisateur choisit une **propriété**, le compte qui la voit est épinglé côté serveur. C'est le modèle livré par la refonte GSC, ne le contourne pas.
- Tout `SelectContent` porte `position="popper"` (piège Radix connu du projet : le défaut `item-aligned` pousse l'option de tête hors du viewport).

**Copie à écrire** (namespace `Onboarding`, clés `gscTitle`, `gscHelp`, `gscSkip`, plus les états) : le titre dit ce que l'étape apporte (« Tes vraies données de recherche »), l'aide explique en une phrase que Search Console remonte les requêtes sur lesquelles le site apparaît déjà, et la sortie dit explicitement qu'un site neuf n'a pas encore de données et que ce n'est pas un problème.

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

Couvre les quatre états ci-dessus, plus la sortie. Le test doit vérifier qu'un clic sur « Passer » avance le wizard **sans** appeler `setSiteGscPropertyAction`, et qu'une sélection de propriété l'appelle avec le `siteId` courant.

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

Run: `bun run test src/components/onboarding/steps/gsc-step.test.tsx`
Expected: FAIL — le composant n'existe pas.

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

Suis le style des autres étapes du wizard (`src/components/onboarding/steps/`). Réutilise les composants shadcn existants ; passe par le skill `shadcn` si tu dois en ajouter un.

- [ ] **Step 4: Ajouter les clés i18n**

`messages/en.json` et `messages/fr.json`, namespace `Onboarding`, mêmes clés et même arborescence dans les deux fichiers. Seules ces deux locales existent.

- [ ] **Step 5: Monter l'étape dans le wizard**

Dans `src/app/onboarding/page.tsx`, insérer `GscStep` en position 2, entre l'étape site et l'étape mots-clés.

- [ ] **Step 6: Vérifier**

Run: `bun run test src/components/onboarding/ && bunx tsc --noEmit`

- [ ] **Step 7: Commit**

```bash
git add src/components/onboarding/steps/gsc-step.tsx src/components/onboarding/steps/gsc-step.test.tsx src/app/onboarding/page.tsx messages/en.json messages/fr.json
git commit -m "feat(onboarding): etape Search Console dans le parcours, jamais bloquante"
```

---

### Task 7 : Étape 3 du wizard — peupler les mots-clés

Trois chemins, présentés dans l'ordre de valeur décroissante : Search Console, import CSV, saisie manuelle. La saisie manuelle est le filet de sécurité, pas un chemin recommandé.

**Files:**
- Create: `src/components/onboarding/steps/keywords-step.tsx`
- Modify: `messages/en.json`, `messages/fr.json`

**Interfaces:**
- Consumes: `ImportKeywordsDialog`, `ImportGscDialog` (surfaces existantes du réservoir), `addKeywordAction`.
- Produces: `KeywordsStep({ siteId, canImportGsc, analyticsEnabled, onDone, onDefer })`.

- [ ] **Step 1: Écrire l'étape**

Créer `src/components/onboarding/steps/keywords-step.tsx` :

```tsx
"use client";

import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

type Source = "gsc" | "csv" | "manual";

export function KeywordsStep({
  canImportGsc,
  analyticsEnabled,
  onPick,
  onDefer,
}: {
  canImportGsc: boolean;
  analyticsEnabled: boolean;
  onPick: (source: Source) => void;
  onDefer: () => void;
}) {
  const t = useTranslations("Onboarding");
  const [busy] = useState(false);

  return (
    <Card className="w-full max-w-lg">
      <CardHeader>
        <CardTitle>{t("keywordsTitle")}</CardTitle>
        <CardDescription>{t("keywordsHelp")}</CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-2">
        {canImportGsc ? (
          <Button variant="outline" disabled={busy} onClick={() => onPick("gsc")}>
            {t("keywordsFromGsc")}
          </Button>
        ) : null}
        {analyticsEnabled ? (
          <Button variant="outline" disabled={busy} onClick={() => onPick("csv")}>
            {t("keywordsFromCsv")}
          </Button>
        ) : null}
        <Button variant="ghost" disabled={busy} onClick={() => onPick("manual")}>
          {t("keywordsManual")}
        </Button>
      </CardContent>
      <CardFooter className="justify-end">
        <Button variant="outline" onClick={onDefer}>
          {t("later")}
        </Button>
      </CardFooter>
    </Card>
  );
}
```

La saisie manuelle est volontairement en `ghost` ici — c'est le seul endroit du parcours où l'action de repli doit être moins visible que les autres, parce que ce n'est pas une sortie mais un chemin dégradé. La sortie du wizard, elle, reste en `outline`.

- [ ] **Step 2: Clés i18n**

`messages/en.json` :

```json
    "keywordsTitle": "Bring in your keywords",
    "keywordsHelp": "Connect the Google account that has access to your Search Console — it does not have to be the one you signed up with.",
    "keywordsFromGsc": "Import from Search Console",
    "keywordsFromCsv": "Import a CSV (Mangools, Semrush, SE Ranking, Ahrefs)",
    "keywordsManual": "Add a single keyword by hand",
    "later": "I'll do this later"
```

`messages/fr.json` :

```json
    "keywordsTitle": "Fais entrer tes mots-clés",
    "keywordsHelp": "Connecte le compte Google qui a accès à ta Search Console — ce n'est pas forcément celui avec lequel tu t'es inscrit.",
    "keywordsFromGsc": "Importer depuis Search Console",
    "keywordsFromCsv": "Importer un CSV (Mangools, Semrush, SE Ranking, Ahrefs)",
    "keywordsManual": "Ajouter un mot-clé à la main",
    "later": "Je le ferai plus tard"
```

Le libellé de `keywordsHelp` est délibéré : l'administrateur ne s'inscrit pas forcément avec le compte Google qui détient la Search Console, et s'inscrire via Google ne donne pas le scope `webmasters.readonly`.

- [ ] **Step 3: Vérifier**

Run: `bun run test src/i18n && bunx --bun tsc --noEmit`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add src/components/onboarding/steps/keywords-step.tsx messages/en.json messages/fr.json
git commit -m "feat(onboarding): etape 2 du wizard, peupler les mots-cles

Trois chemins par valeur decroissante. Le libelle GSC dit explicitement
que le compte Google peut differer du compte d'inscription."
```

---

### Task 8 : Étape 4 du wizard — enrichissement, explicitement facultative

**Files:**
- Create: `src/components/onboarding/steps/enrich-step.tsx`
- Modify: `messages/en.json`, `messages/fr.json`

**Interfaces:**
- Consumes: `connectMangoolsAction` (`src/lib/connectors/actions.ts`), retour `{ ok: true } | { ok: false; error: "invalid_token" | "forbidden" }`.
- Produces: `EnrichStep({ onDone, onSkip })`.

- [ ] **Step 1: Écrire l'étape**

Créer `src/components/onboarding/steps/enrich-step.tsx` :

```tsx
"use client";

import { useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { connectMangoolsAction } from "@/lib/connectors/actions";

const TOKEN_URL = "https://mangools.com/api-token";

export function EnrichStep({
  onDone,
  onSkip,
}: {
  onDone: () => void;
  onSkip: () => void;
}) {
  const t = useTranslations("Onboarding");
  const [token, setToken] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  function connect() {
    setError(null);
    startTransition(async () => {
      try {
        const res = await connectMangoolsAction(token.trim());
        if (!res.ok) {
          setError(t(res.error === "forbidden" ? "enrichForbidden" : "enrichInvalid"));
          return;
        }
        onDone();
      } catch {
        // connectMangoolsAction throw si la session n'a pas d'org : /onboarding
        // n'a pas d'error boundary, on rattrape ici.
        setError(t("enrichFailed"));
      }
    });
  }

  return (
    <Card className="w-full max-w-lg">
      <CardHeader>
        <CardTitle>{t("enrichTitle")}</CardTitle>
        <CardDescription>{t("enrichHelp")}</CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-3">
        <Input
          type="password"
          value={token}
          onChange={(e) => setToken(e.target.value)}
          placeholder={t("enrichPlaceholder")}
          autoComplete="off"
        />
        <a
          className="text-muted-foreground text-sm underline"
          href={TOKEN_URL}
          target="_blank"
          rel="noreferrer"
        >
          {t("enrichWhereToken")}
        </a>
        {error ? (
          <p className="text-destructive text-sm" role="alert">
            {error}
          </p>
        ) : null}
      </CardContent>
      <CardFooter className="justify-between">
        <Button variant="outline" onClick={onSkip} disabled={pending}>
          {t("enrichSkip")}
        </Button>
        <Button onClick={connect} disabled={!token.trim() || pending}>
          {t("enrichConnect")}
        </Button>
      </CardFooter>
    </Card>
  );
}
```

Le `try/catch` corrige un défaut de l'étape « Outils » actuelle : `connectMangoolsAction` throw quand la session n'a pas d'organisation, et `/onboarding` n'a aucune error boundary.

- [ ] **Step 2: Clés i18n**

`messages/en.json` :

```json
    "enrichTitle": "Add search volumes (optional)",
    "enrichHelp": "Connect Mangools to get volume, difficulty and CPC on your keywords. You can skip this and do it later from Settings.",
    "enrichPlaceholder": "Mangools API token",
    "enrichWhereToken": "Where do I find this token?",
    "enrichConnect": "Connect",
    "enrichSkip": "Skip this step",
    "enrichInvalid": "That token was rejected. Check you copied it in full.",
    "enrichForbidden": "Only an owner or admin can connect a tool.",
    "enrichFailed": "Connection failed. Try again in a moment."
```

`messages/fr.json` :

```json
    "enrichTitle": "Ajoute les volumes de recherche (facultatif)",
    "enrichHelp": "Connecte Mangools pour obtenir volume, difficulté et CPC sur tes mots-clés. Tu peux passer cette étape et le faire plus tard depuis les Réglages.",
    "enrichPlaceholder": "Token API Mangools",
    "enrichWhereToken": "Où trouver ce token ?",
    "enrichConnect": "Connecter",
    "enrichSkip": "Passer cette étape",
    "enrichInvalid": "Ce token a été refusé. Vérifie que tu l'as copié en entier.",
    "enrichForbidden": "Seul un propriétaire ou un administrateur peut connecter un outil.",
    "enrichFailed": "La connexion a échoué. Réessaie dans un instant."
```

- [ ] **Step 3: Vérifier et commiter**

Run: `bun run test src/i18n && bunx --bun tsc --noEmit`
Expected: PASS.

```bash
git add src/components/onboarding/steps/enrich-step.tsx messages/en.json messages/fr.json
git commit -m "feat(onboarding): etape 3 du wizard, enrichissement facultatif

Le titre annonce explicitement que l'etape est optionnelle, et l'action
de sortie est en outline. Le throw de connectMangoolsAction est
rattrape : /onboarding n'a pas d'error boundary."
```

---

### Task 9 : Étape 5 du wizard — cibler un pays

Dernière étape du chemin critique. Elle appelle `setKeywordTargetAction`, **seul** chemin du repo qui écrit à la fois le `keyword_target` au statut `planned` et le stage `planning` — les deux autres chemins de ciblage produisent des états incohérents.

**Files:**
- Create: `src/components/onboarding/steps/target-step.tsx`
- Create: `src/lib/onboarding/first-target.ts`
- Create: `src/lib/onboarding/first-target.test.ts`
- Modify: `messages/en.json`, `messages/fr.json`

**Interfaces:**
- Consumes: `setKeywordTargetAction(keywordIdValue: string, country: string, status: TargetStatus | null): Promise<void>`.
- Produces: `pickFirstTargetCandidate(keywords, countries)`, `TargetStep({ candidates, countries, onDone, onDefer })`.

- [ ] **Step 1: Écrire le test du choix de candidat**

Créer `src/lib/onboarding/first-target.test.ts` :

```ts
import { describe, expect, it } from "bun:test";
import { pickFirstTargetCandidate } from "./first-target";

describe("pickFirstTargetCandidate", () => {
  it("retourne null sans mot-clé", () => {
    expect(pickFirstTargetCandidate([], ["FR"])).toBeNull();
  });

  it("retourne null sans pays cible sur le site", () => {
    expect(
      pickFirstTargetCandidate([{ id: "k1", term: "alpha", volume: 100 }], []),
    ).toBeNull();
  });

  it("propose le mot-clé au plus fort volume et le premier pays du site", () => {
    expect(
      pickFirstTargetCandidate(
        [
          { id: "k1", term: "alpha", volume: 100 },
          { id: "k2", term: "beta", volume: 900 },
          { id: "k3", term: "gamma", volume: null },
        ],
        ["FR", "BE"],
      ),
    ).toEqual({ keywordId: "k2", country: "FR" });
  });

  it("retombe sur le premier mot-clé quand aucun volume n'est connu", () => {
    expect(
      pickFirstTargetCandidate(
        [
          { id: "k1", term: "alpha", volume: null },
          { id: "k2", term: "beta", volume: null },
        ],
        ["FR"],
      ),
    ).toEqual({ keywordId: "k1", country: "FR" });
  });
});
```

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

Run: `bun run test src/lib/onboarding/first-target.test.ts`
Expected: FAIL — `Cannot find module './first-target'`.

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

Créer `src/lib/onboarding/first-target.ts` :

```ts
/**
 * Choisit le mot-clé et le pays à proposer pour le premier ciblage.
 * Volume décroissant, `null` en dernier — même ordre que l'index
 * keywords_site_volume_idx (volume DESC NULLS LAST).
 */

export type TargetCandidate = { id: string; term: string; volume: number | null };
export type FirstTarget = { keywordId: string; country: string };

export function pickFirstTargetCandidate(
  candidates: readonly TargetCandidate[],
  countries: readonly string[],
): FirstTarget | null {
  const country = countries[0];
  if (!country || candidates.length === 0) return null;

  const best = [...candidates].sort((a, b) => (b.volume ?? -1) - (a.volume ?? -1))[0];
  return { keywordId: best.id, country };
}
```

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

Run: `bun run test src/lib/onboarding/first-target.test.ts`
Expected: PASS — 4 tests.

- [ ] **Step 5: Écrire l'étape**

Créer `src/components/onboarding/steps/target-step.tsx` :

```tsx
"use client";

import { useTransition } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { setKeywordTargetAction } from "@/lib/keywords/target-actions";
import type { FirstTarget } from "@/lib/onboarding/first-target";

export function TargetStep({
  target,
  termLabel,
  onDone,
  onDefer,
}: {
  target: FirstTarget | null;
  termLabel: string | null;
  onDone: () => void;
  onDefer: () => void;
}) {
  const t = useTranslations("Onboarding");
  const [pending, startTransition] = useTransition();

  function plan() {
    if (!target) return;
    startTransition(async () => {
      // Seul chemin du repo qui pose À LA FOIS le keyword_target `planned`
      // et le stage `planning` : le ciblage en masse et le bouton « → Planning »
      // n'en écrivent qu'un des deux.
      await setKeywordTargetAction(target.keywordId, target.country, "planned");
      onDone();
    });
  }

  return (
    <Card className="w-full max-w-lg">
      <CardHeader>
        <CardTitle>{t("targetTitle")}</CardTitle>
        <CardDescription>{t("targetHelp")}</CardDescription>
      </CardHeader>
      <CardContent>
        {target && termLabel ? (
          <p className="text-sm">
            {t("targetProposal", { term: termLabel, country: target.country })}
          </p>
        ) : (
          <p className="text-muted-foreground text-sm">{t("targetNoCandidate")}</p>
        )}
      </CardContent>
      <CardFooter className="justify-between">
        <Button variant="outline" onClick={onDefer} disabled={pending}>
          {t("later")}
        </Button>
        <Button onClick={plan} disabled={!target || pending}>
          {t("targetConfirm")}
        </Button>
      </CardFooter>
    </Card>
  );
}
```

Vérifier le chemin d'import de `setKeywordTargetAction` avant d'écrire :

```bash
bunx --bun rg -n "export async function setKeywordTargetAction" src/
```

- [ ] **Step 6: Clés i18n**

`messages/en.json` :

```json
    "targetTitle": "Plan your first keyword",
    "targetHelp": "Planning a keyword for a country puts it on your content plan and unlocks the brief workflow.",
    "targetProposal": "We'll plan \"{term}\" for {country} — your highest-volume keyword.",
    "targetNoCandidate": "No keyword available yet. Go back and import some first.",
    "targetConfirm": "Plan it"
```

`messages/fr.json` :

```json
    "targetTitle": "Planifie ton premier mot-clé",
    "targetHelp": "Planifier un mot-clé pour un pays le fait entrer dans ton plan de contenu et débloque le circuit de brief.",
    "targetProposal": "On planifie « {term} » pour {country} — ton mot-clé au plus fort volume.",
    "targetNoCandidate": "Aucun mot-clé disponible pour l'instant. Reviens en arrière pour en importer.",
    "targetConfirm": "Planifier"
```

- [ ] **Step 7: Vérifier et commiter**

Run: `bun run test src/lib/onboarding && bun run test src/i18n && bunx --bun tsc --noEmit`
Expected: PASS.

```bash
git add src/components/onboarding/steps/target-step.tsx src/lib/onboarding/first-target.ts src/lib/onboarding/first-target.test.ts messages/en.json messages/fr.json
git commit -m "feat(onboarding): etape 4 du wizard, planifier un premier mot-cle

Appelle setKeywordTargetAction, seul chemin qui pose a la fois le
keyword_target planned et le stage planning."
```

---

### Task 10 : Recâbler le wizard et retirer l'ancien système

**⚠️ Correction obligatoire du câblage de l'étape enrichissement.** Le code de cette tâche câble `onDone={reloadWizard}` sur `EnrichStep`. **C'est faux.** `enrich-keywords` est dérivé de `keywords.volume_primary IS NOT NULL`, pas de la présence d'un connecteur : brancher un jeton d'API ne fait pas passer le drapeau à vrai, et l'enrichissement lui-même est un traitement asynchrone. Un rechargement recomposerait donc le parcours **sur la même étape, formulaire vide**, juste après une connexion réussie — exactement le défaut corrigé à la tâche 7 sur l'import à zéro ligne. Utilise `onDone={() => skipTo("target-country")}`. L'avertissement est aussi porté par la JSDoc de la prop.

**⚠️ `getTargetCandidates(siteId)` n'existe nulle part — et sa règle de filtrage est load-bearing.**

Cette tâche doit l'écrire. Deux exigences, la première étant une **protection contre une régression de données** :

1. **Exclure les mots-clés portant déjà une cible sur le premier pays du site.** `setKeywordTargetAction` fait un `onConflictDoUpdate({ set: { status } })` aveugle (`src/lib/keywords/actions.ts:191-197`), alors que `STATUS_RANK` classe `live` au-dessus de `planned` (`src/lib/keywords/targets.ts:27-31`) et que l'action de masse refuse explicitement de rétrograder. Sans ce filtre : un site dont toutes les cibles sont passées en `live` repasse le drapeau `target-country` à faux (il est dérivé de `status = 'planned'`), rouvre l'étape requise, propose de planifier un mot-clé **déjà publié**, et le clic le régresse en `planned` sans un mot.
2. **Trier par volume décroissant, valeurs nulles en dernier.** `first-target.ts:42-44` documente que le départage à volumes tous nuls repose sur ce tri, fait en amont. Rien ne le garantit aujourd'hui : c'est ce chargeur qui doit le tenir.

**Deux points d'arbitrage sur l'étape finale, à trancher ici :**

- *Double sortie.* `target-step.tsx` rend « Je planifierai plus tard » vers `/onboarding/defer`, et le wizard rend déjà, sous chaque étape, un lien vers la même route. Deux contrôles, même destination, libellés quasi identiques. Garde celui de l'étape — c'est la dernière, il n'y a pas de « suivant » — et masque le lien global sur cette étape, ou l'inverse. Mais pas les deux.
- *Retour en arrière.* L'état « réservoir vide » de l'étape finale invite à revenir en arrière, or le wizard n'offre aucun retour sur les étapes de données. Soit tu câbles un `onBack` optionnel vers l'étape mots-clés, soit la copie est reformulée — voir la dette *e)* ci-dessous.

**Cinq dettes à solder ici, relevées pendant l'exécution (2026-07-27).**

*c) Rebrancher le lien de mise à niveau de l'étape mots-clés.* `keywords-step.tsx` affiche l'avertissement de quota **sans lien**, parce que `/settings/billing` vit dans `(app)` et que le layout y redirige vers `/onboarding` tant que les étapes requises manquent — l'utilisateur au plafond retombait donc sur l'étape 1 sans un mot. Une fois le paramètre `to` posé et validé (voir le prérequis ci-dessous), rétablis le lien vers `/onboarding/defer?to=/settings/billing`.

*a) Le retour de paiement Stripe rebondit dans le wizard.* `src/components/onboarding/onboarding-wizard.tsx:95-99` porte un commentaire expliquant que le tampon `onboardingCompletedAt` doit précéder le départ vers Stripe, « sans quoi l'utilisateur paierait puis retomberait dans le wizard ». Cette protection est **morte** depuis que le gate a cessé de lire ce tampon (tâche 4) : le `successUrl` renvoie dans `(app)`, dont le layout redirige vers `/onboarding`, étape 1. Retirer l'étape forfait — ce que cette tâche fait — supprime le chemin ; **vérifie-le explicitement** et supprime le commentaire devenu contresens, plutôt que de le laisser égarer le prochain lecteur.

*b) Un commentaire qui ramènerait au piège.* `src/lib/onboarding/wizard-gate.ts:9` décrit encore le cookie de report comme « posé côté client ». C'est faux depuis la tâche 4 : il est `httpOnly`, posé par `src/app/onboarding/defer/route.ts`. Corrige la ligne — sans quoi elle invite à réimplémenter le `document.cookie` qui échouerait en silence.

**Prérequis ajouté le 2026-07-27 — `/onboarding/defer` doit accepter une destination.**

La tâche 4 a livré `src/app/onboarding/defer/route.ts`, qui pose le cookie de report **en `httpOnly`** et redirige en dur vers `/keywords/planning`. Deux conséquences pour toi :

1. **N'écris jamais le cookie côté client.** Un `document.cookie` échouerait *en silence* : le bouton paraîtrait fonctionner, et l'utilisateur reviendrait au wizard à la navigation suivante. Toutes les sorties passent par la route.
2. **Ajoute un paramètre `to` à la route**, pour que le chemin « j'importe mes mots-clés ailleurs » dépose l'utilisateur sur la bonne surface avec le report déjà posé.

**Validation obligatoire du paramètre `to`** — sans elle tu ouvres une redirection arbitraire, exploitable en hameçonnage :

- n'accepte que les chemins **internes** : la valeur doit commencer par `/` et **ne pas** commencer par `//` (une URL protocole-relatif, `//evil.test`, est externe) ;
- refuse toute valeur contenant `://` ou un retour à la ligne ;
- en cas de valeur absente ou refusée, retombe silencieusement sur `/keywords/planning` — jamais d'erreur affichée, c'est une porte de sortie.

Écris un test pour chacun de ces cas de refus : c'est le genre de garde qu'un futur nettoyage supprime s'il ne la voit pas asservie.

**Files:**
- Modify: `src/components/onboarding/onboarding-wizard.tsx`
- Modify: `src/app/onboarding/page.tsx`
- Modify: `src/app/(app)/layout.tsx`
- Delete: `src/components/onboarding/steps/language-step.tsx`, `plan-step.tsx`, `team-step.tsx`, `tools-step.tsx`
- Delete: `src/components/site/first-site-onboarding-dialog.tsx`
- Delete: `src/lib/onboarding/activation/engine.tsx`, `engine-steps.ts`, `engine.test.ts`
- Delete: `src/components/onboarding/activation-error-boundary.tsx`
- Modify: `package.json` (retrait de `@onboardjs/core` et `@onboardjs/react`)
- Modify: `messages/en.json`, `messages/fr.json`

- [ ] **Step 1: Recâbler le wizard sur les nouvelles étapes**

Remplacer intégralement `src/components/onboarding/onboarding-wizard.tsx` :

```tsx
"use client";

import { useState } from "react";
import { useTranslations } from "next-intl";
import { EnrichStep } from "@/components/onboarding/steps/enrich-step";
import { KeywordsStep } from "@/components/onboarding/steps/keywords-step";
import { SiteStep } from "@/components/onboarding/steps/site-step";
import { TargetStep } from "@/components/onboarding/steps/target-step";
import {
  WIZARD_STEP_IDS,
  type WizardStepId,
} from "@/lib/onboarding/activation/steps";
import type { FirstTarget } from "@/lib/onboarding/first-target";
import { DEFER_COOKIE } from "@/lib/onboarding/wizard-gate";

/** Premier écran qui montre un plan de travail constitué. */
const LANDING = "/keywords/planning";

export function OnboardingWizard({
  initialStep,
  analyticsEnabled,
  candidates,
  site,
}: {
  initialStep: WizardStepId;
  analyticsEnabled: boolean;
  // CORRECTION 2026-07-27 : `firstTarget`/`firstTargetTerm` remplacés par les
  // candidats bruts et le site — l'étape finale doit distinguer « réservoir
  // vide » de « site sans pays cible ». `canImportGsc` disparaît : Search
  // Console est une étape à part entière depuis la tâche 6.
  candidates: FirstTargetCandidate[];
  site: { targetCountries: string[] | null } | null;
}) {
  const t = useTranslations("Onboarding");
  const [step, setStep] = useState<WizardStepId>(initialStep);

  /** Pose le report pour la session puis quitte le wizard.
   *
   *  CORRECTION 2026-07-27 : ne PAS écrire `document.cookie`. Le cookie de
   *  report est posé `httpOnly` par la route serveur `/onboarding/defer`
   *  (livrée en tâche 4), donc une écriture côté client échouerait **en
   *  silence** — le bouton paraîtrait fonctionner et l'utilisateur
   *  reviendrait au wizard à la navigation suivante. On navigue vers la
   *  route, qui pose le cookie et redirige elle-même. */
  function defer() {
    window.location.href = "/onboarding/defer";
  }

  /**
   * Rechargement dur plutôt qu'avance locale : l'étape suivante dépend de
   * l'état dérivé côté serveur, qui vient de changer.
   */
  function reloadWizard() {
    window.location.href = "/onboarding";
  }

  function skipTo(next: WizardStepId) {
    setStep(next);
  }

  const index = WIZARD_STEP_IDS.indexOf(step);

  return (
    <div className="flex w-full flex-col items-center gap-6">
      <p className="text-muted-foreground text-sm">
        {t("stepCounter", { current: index + 1, total: WIZARD_STEP_IDS.length })}
      </p>

      {step === "create-site" ? <SiteStep onCreated={reloadWizard} /> : null}

      {step === "add-keywords" ? (
        <KeywordsStep
          canImportGsc={canImportGsc}
          analyticsEnabled={analyticsEnabled}
          onPick={(source) => {
            // Les surfaces d'import vivent dans l'app : on y dépose
            // l'utilisateur avec le report posé, la checklist prend le relais.
            // CORRECTION 2026-07-27 : le cookie est `httpOnly` (route serveur
            // `/onboarding/defer`, tâche 4), donc pas d'écriture côté client —
            // elle échouerait en silence. On passe la destination à la route.
            window.location.href = `/onboarding/defer?to=${encodeURIComponent(
              `/keywords/reservoir?import=${source}`,
            )}`;
          }}
          onDefer={defer}
        />
      ) : null}

      {step === "enrich-keywords" ? (
        // PAS `reloadWizard` ici : `enrich-keywords` est dérivé des volumes
        // en base, pas du connecteur, et l'enrichissement est asynchrone —
        // un rechargement ramènerait sur cette même étape, formulaire vide,
        // juste après une connexion réussie. Voir la note en tête de tâche.
        <EnrichStep
          onDone={() => skipTo("target-country")}
          onSkip={() => skipTo("target-country")}
        />
      ) : null}

      {step === "target-country" ? (
        // CORRECTION 2026-07-27 — signature réelle de l'étape livrée.
        // `target`/`termLabel` n'existent pas : avec un seul `target: null`,
        // l'étape ne pourrait pas distinguer « réservoir vide » de « site sans
        // pays cible », deux causes du même null qui appellent deux gestes
        // opposés. Elle reçoit donc les candidats et les pays, et dérive
        // elle-même son état (`ready` | `no-keyword` | `no-country`).
        // Pas de `onDefer` non plus : l'étape rend sa propre sortie vers la
        // route serveur `/onboarding/defer`.
        <TargetStep
          candidates={candidates}
          countries={site?.targetCountries ?? []}
          onDone={() => {
            window.location.href = LANDING;
          }}
        />
      ) : null}
    </div>
  );
}
```

Ajouter la clé `stepCounter` dans les deux catalogues : `"Step {current} of {total}"` / `"Étape {current} sur {total}"`.

Ce fichier ne référence plus `PlanSelection`, `authClient.subscription.upgrade` ni `completeOnboarding` : le choix de forfait quitte le parcours d'entrée, il vit déjà dans Réglages → Facturation et sur `/pricing`.

- [ ] **Step 1b: Calculer les props côté serveur**

Dans `src/app/onboarding/page.tsx`, après le snapshot d'activation, déterminer l'étape initiale et le candidat de ciblage :

```tsx
  const firstWizardStep =
    WIZARD_STEP_IDS.find((id) => !snapshot.flags[id]) ?? "target-country";

  const siteId = (await getSitesForActiveOrg())[0]?.id ?? null;
  const candidates = siteId ? await getTargetCandidates(siteId) : [];
  const site = siteId ? await getSiteById(siteId, activeOrgId) : null;
  const firstTarget = pickFirstTargetCandidate(
    candidates,
    site?.targetCountries ?? [],
  );
  const firstTargetTerm =
    candidates.find((c) => c.id === firstTarget?.keywordId)?.term ?? null;
```

`getTargetCandidates(siteId)` est une lecture à ajouter dans `src/lib/keywords/queries.ts`, qui retourne exactement le type `TargetCandidate` de la Task 8 :

```ts
export async function getTargetCandidates(
  siteId: string,
): Promise<TargetCandidate[]> {
  return db
    .select({
      id: keywords.id,
      term: keywords.term,
      volume: keywords.volumePrimary,
    })
    .from(keywords)
    .where(eq(keywords.siteId, siteId))
    .orderBy(sql`${keywords.volumePrimary} DESC NULLS LAST`)
    .limit(20);
}
```

L'ordre correspond exactement à l'index `keywords_site_volume_idx`.

- [ ] **Step 2: Retirer la modale premier site**

Dans `src/app/(app)/layout.tsx`, supprimer `showFirstSiteDialog`, son rendu et l'import de `FirstSiteOnboardingDialog`, puis supprimer le fichier. Le wizard prend l'action en charge et le gate de la Task 4 y ramène tant que `create-site` est faux. Conserver `UsernamePromptDialog` et sa précédence.

- [ ] **Step 3: Retirer OnboardJS**

```bash
bunx --bun rg -n "@onboardjs" src/ package.json
```

Supprimer les fichiers listés ci-dessus, puis :

```bash
bun remove @onboardjs/core @onboardjs/react
```

- [ ] **Step 4: Retirer les clés i18n mortes**

Supprimer de `messages/en.json` et `messages/fr.json`, namespace `Onboarding` : `planFree`, `planFreeDesc`, `planComingSoon`, `planPro`, `planAgency`, ainsi que les clés des étapes supprimées (`stepLanguage`, `stepPlan`, `stepTeam`, `stepTools`, `languageTitle`, `languageHelp`).

*(Le renommage des clés `Activation.steps` a été déplacé en tâche 3 — voir la note en tête de cette tâche.)*

Vérifier qu'aucune clé supprimée n'est encore référencée :

```bash
bunx --bun rg -n "planFree|planComingSoon|stepLanguage|first-enrichment|import-keywords|add-site" src/ messages/
```

- [ ] **Step 5: Vérifier**

Run: `bun run test:all`
Expected: PASS sur toute la suite.

Run: `bun run lint && bunx --bun tsc --noEmit && bun run build`
Expected: aucune erreur.

- [ ] **Step 6: Vérification visuelle**

Avec d3k, sur un compte neuf : parcourir les quatre étapes jusqu'à l'atterrissage sur `/keywords/planning`, vérifier que la ligne planifiée y apparaît, puis vérifier que la checklist de la sidebar affiche le même état et disparaît une fois les trois étapes requises faites.

- [ ] **Step 7: Commit**

```bash
git add -A
git commit -m "refactor(onboarding): un seul systeme d'onboarding

Le wizard suit le chemin critique et partage sa machine a etats avec la
checklist. La modale premier site et OnboardJS disparaissent : la lib ne
produisait qu'une valeur aussitot recalculee depuis les flags serveur."
```

---

## Fin de branche

- [ ] `bun run test:all` vert.
- [ ] `bun run lint`, `bunx --bun tsc --noEmit`, `bun run build` sans erreur.
- [ ] `bunx --bun rg -n "from \"radix-ui\"" src/components/onboarding src/components/site` — aucun résultat dans le nouveau code.
- [ ] Revue de `git diff dev..HEAD`.
- [ ] Merge ff-only dans `dev`, push, suppression de la branche. **Ne pas toucher à `main`.**
