# Checklist d'activation — 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:** Carte d'activation dans la sidebar (5 étapes dérivées de l'état produit) avec panneau détaillé, moteur OnboardJS isolé derrière un adaptateur, dismissal par (user, org).

**Architecture:** La complétion n'est JAMAIS stockée — `derive.ts` la calcule depuis la DB (requêtes EXISTS). OnboardJS (client-only) ne sert qu'à l'UX du panneau et reçoit l'état en `initialContext`; aucun handler de persistance. La carte suit le pattern éprouvé de `keyword-quota-bar.tsx` : client component + server action au mount, refetch sur `pathname` et `focus` (le layout ne re-render pas à la navigation — une prop RSC serait stale). Seule donnée nouvelle : table `activation_dismissals`.

**Tech Stack:** Next.js 16.2.7 App Router, React 19.2.7, Drizzle + postgres.js, better-auth 1.6.17, next-intl 4.13, bun, Biome, `@onboardjs/core` (rc) + `@onboardjs/react` (rc).

**Spec:** `docs/superpowers/specs/2026-07-02-activation-checklist-design.md`. Deux ajustements actés vs la spec (faits codebase) : (1) « ≥1 ligne enrichment_exports » se traduit par `keywords.enrichment_exports != '{}'::jsonb` (c'est une colonne jsonb par keyword, PAS une table) ; (2) la dérivation est exposée via une server action read-only appelée par la carte cliente (pattern quota bar), pas via prop RSC.

## Global Constraints

- Branche de travail : `feature/activation-checklist` (existe déjà, spec committée dessus). JAMAIS de commit sur `main`.
- Conventional Commits obligatoires (`feat:` / `test:` / `docs:`…).
- Toute nouvelle dépendance s'installe via `sfw bun add <pkg>` (Socket Firewall).
- i18n : toute string UI passe par `messages/{en,fr,es,de}.json` — le test de parité `src/i18n/catalogs.test.ts` casse si une locale manque une clé.
- Gate par tâche : `bunx tsc --noEmit` (0 erreur), `bun run lint` (biome, 0 erreur), tests de la tâche verts.
- Tests DB : gated `DB_TESTS=1`, base `seo_insight_test` uniquement (`assertTestDb()` avant tout delete). Commande : `bun run test <fichier>` (le script `test` du package.json met l'env complet : DB_TESTS, DATABASE_URL test, --env-file, --conditions=react-server).
- Sécurité endpoints : les fichiers `"use server"` n'exportent QUE des actions auth-guardées ; la logique DB privilégiée vit dans des fichiers `import "server-only"` séparés (pattern `src/lib/onboarding/complete.ts`).
- Le fichier `src/lib/onboarding/activation/engine.tsx` est le SEUL du repo autorisé à importer `@onboardjs/*`.
- Ne jamais proposer de pousser en prod ni merger `main`.

---

### Task 1: Fondation — deps, types, steps, migration

**Files:**
- Create: `src/lib/onboarding/activation/types.ts`
- Create: `src/lib/onboarding/activation/steps.ts`
- Create: `src/lib/onboarding/activation/steps.test.ts`
- Modify: `src/lib/db/domain-schema.ts` (ajout table en fin de fichier)
- Create (générés): `drizzle/0049_*.sql` + meta via `bun run db:generate`
- Modify: `package.json` (deps @onboardjs via sfw bun add)

**Interfaces:**
- Consumes: rien (première tâche).
- Produces (contrat pour les tâches 2-4) :
  - `ACTIVATION_STEP_IDS: readonly ["add-site","connect-gsc","import-keywords","first-enrichment","invite-teammate"]`
  - `type ActivationStepId`, `type ActivationFlags = Record<ActivationStepId, boolean>`
  - `type ActivationStepDef = { id: ActivationStepId; required: boolean; href: string }`
  - `type ActivationSnapshot = { flags: ActivationFlags | null; dismissed: boolean }`
  - `ACTIVATION_STEPS: readonly ActivationStepDef[]` (ordre canonique)
  - `requiredComplete(flags): boolean`, `doneCount(flags): number`, `firstIncompleteStep(flags): ActivationStepId | null`
  - Table Drizzle `activationDismissals` (`activation_dismissals`, PK (user_id, org_id))

- [ ] **Step 1: Installer OnboardJS**

```bash
sfw bun add @onboardjs/core @onboardjs/react
```

Vérifier ensuite : `grep onboardjs package.json` montre les deux packages (versions 1.0.0-rc.x).

- [ ] **Step 2: Écrire `types.ts`**

```ts
// src/lib/onboarding/activation/types.ts — types partagés client/serveur (pas de directive).

export const ACTIVATION_STEP_IDS = [
  "add-site",
  "connect-gsc",
  "import-keywords",
  "first-enrichment",
  "invite-teammate",
] as const;

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

/** Complétion dérivée de l'état produit — jamais stockée. */
export type ActivationFlags = Record<ActivationStepId, boolean>;

export type ActivationStepDef = {
  id: ActivationStepId;
  required: boolean;
  /** Cible de navigation du CTA. */
  href: string;
};

/** Retour de getActivationAction. flags null quand dismissed (derive court-circuitée). */
export type ActivationSnapshot = {
  flags: ActivationFlags | null;
  dismissed: boolean;
};
```

- [ ] **Step 3: Écrire le test de `steps.ts` (failing)**

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

const none: ActivationFlags = {
  "add-site": false,
  "connect-gsc": false,
  "import-keywords": false,
  "first-enrichment": false,
  "invite-teammate": false,
};
const all: ActivationFlags = {
  "add-site": true,
  "connect-gsc": true,
  "import-keywords": true,
  "first-enrichment": true,
  "invite-teammate": true,
};

describe("activation steps config", () => {
  it("couvre exactement les 5 ids, dans l'ordre canonique", () => {
    expect(ACTIVATION_STEPS.map((s) => s.id)).toEqual([...ACTIVATION_STEP_IDS]);
  });

  it("seule l'invitation est optionnelle", () => {
    expect(
      ACTIVATION_STEPS.filter((s) => !s.required).map((s) => s.id),
    ).toEqual(["invite-teammate"]);
  });

  it("requiredComplete ignore l'étape optionnelle", () => {
    expect(requiredComplete(none)).toBe(false);
    expect(requiredComplete({ ...all, "invite-teammate": false })).toBe(true);
  });

  it("doneCount compte toutes les étapes, optionnelle incluse", () => {
    expect(doneCount(none)).toBe(0);
    expect(doneCount(all)).toBe(5);
    expect(doneCount({ ...none, "invite-teammate": true })).toBe(1);
  });

  it("firstIncompleteStep suit l'ordre canonique et retourne null quand tout est fait", () => {
    expect(firstIncompleteStep(none)).toBe("add-site");
    expect(firstIncompleteStep({ ...none, "add-site": true })).toBe("connect-gsc");
    expect(firstIncompleteStep(all)).toBeNull();
  });
});
```

- [ ] **Step 4: Vérifier que le test échoue**

Run: `bun run test src/lib/onboarding/activation/steps.test.ts`
Expected: FAIL (module `./steps` introuvable).

- [ ] **Step 5: Écrire `steps.ts`**

```ts
// src/lib/onboarding/activation/steps.ts — définition canonique des étapes + helpers purs.

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

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

/** Activation complète = les 4 étapes obligatoires (l'optionnelle ne bloque pas). */
export function requiredComplete(flags: ActivationFlags): boolean {
  return ACTIVATION_STEPS.filter((s) => s.required).every((s) => flags[s.id]);
}

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

/** Étape suggérée = première incomplète dans l'ordre canonique. */
export function firstIncompleteStep(flags: ActivationFlags): ActivationStepId | null {
  return ACTIVATION_STEPS.find((s) => !flags[s.id])?.id ?? null;
}
```

- [ ] **Step 6: Vérifier que le test passe**

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

- [ ] **Step 7: Ajouter la table `activationDismissals` au schéma**

En fin de `src/lib/db/domain-schema.ts` (les imports `organization`, `user` depuis `./auth-schema` et `pgTable/text/timestamp/primaryKey` depuis `drizzle-orm/pg-core` existent déjà en tête de fichier — vérifier et compléter si besoin) :

```ts
/** Dismissal de la checklist d'activation, par (user, org). La progression
 *  elle-même n'est JAMAIS stockée : elle est dérivée de l'état produit
 *  (src/lib/onboarding/activation/derive.ts). */
export const activationDismissals = pgTable(
  "activation_dismissals",
  {
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    orgId: text("org_id")
      .notNull()
      .references(() => organization.id, { onDelete: "cascade" }),
    dismissedAt: timestamp("dismissed_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (t) => [primaryKey({ columns: [t.userId, t.orgId] })],
);
```

- [ ] **Step 8: Générer et appliquer la migration**

```bash
bun run db:generate        # doit créer drizzle/0049_*.sql
bun run db:migrate         # DB dev
bun run db:migrate:test    # DB test
```

Vérifier le SQL généré : `CREATE TABLE "activation_dismissals"` avec PK composite et 2 FK cascade. (Si le dev server tourne, un burst transitoire « column does not exist » peut apparaître dans ses logs — connu, re-tester avant de diagnostiquer.)

- [ ] **Step 9: Gate + commit**

```bash
bunx tsc --noEmit && bun run lint
git add package.json bun.lock src/lib/onboarding/activation/ src/lib/db/domain-schema.ts drizzle/
git commit -m "feat(onboarding): activation checklist foundation — types, steps, dismissals table"
```

---

### Task 2: Couche serveur — derive, dismiss, actions

**Files:**
- Create: `src/lib/onboarding/activation/derive.ts`
- Create: `src/lib/onboarding/activation/derive.test.ts`
- Create: `src/lib/onboarding/activation/dismiss.ts`
- Create: `src/lib/onboarding/activation/actions.ts`
- Create: `src/lib/onboarding/activation/actions.test.ts`

**Interfaces:**
- Consumes (Task 1): `ActivationFlags`, `ActivationSnapshot` depuis `./types` ; table `activationDismissals` depuis `@/lib/db/domain-schema`.
- Produces (pour Task 4) :
  - `deriveActivationFlags(orgId: string): Promise<ActivationFlags>` (server-only)
  - `isActivationDismissed(userId, orgId): Promise<boolean>`, `dismissActivation(userId, orgId)`, `restoreActivation(userId, orgId)` (server-only)
  - Actions `"use server"` : `getActivationAction(): Promise<ActivationSnapshot | null>`, `dismissActivationAction(): Promise<void>`, `restoreActivationAction(): Promise<void>`

- [ ] **Step 1: Écrire le test DB de `derive.ts` (failing)**

Pattern : `src/lib/auth/seat-guard.test.ts` (DB_TESTS gate, `mock.module("server-only")`, imports dynamiques en `beforeEach`, `assertTestDb()` avant les deletes, IDs fixes, cleanup par cascade org).

```ts
// src/lib/onboarding/activation/derive.test.ts
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";

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

const RUN_DB = process.env.DB_TESTS === "1";
const NOW = new Date();
const ORG = "act-derive-org";
const OWNER = "act-derive-owner";
const STAFF = "act-derive-staff";
const SITE = "act-derive-site";

describe.skipIf(!RUN_DB)("deriveActivationFlags (postgres)", () => {
  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques de test
  let db: any;
  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques de test
  let authSchema: any;
  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques de test
  let domain: any;
  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques de test
  let derive: any;

  async function cleanup() {
    const { inArray } = await import("drizzle-orm");
    // org cascade → member, invitation, sites → keywords
    await db.delete(authSchema.organization).where(inArray(authSchema.organization.id, [ORG]));
    await db.delete(authSchema.user).where(inArray(authSchema.user.id, [OWNER, STAFF]));
  }

  async function seedBase() {
    await db.insert(authSchema.user).values({
      id: OWNER, name: OWNER, email: `${OWNER}@test.dev`,
      emailVerified: true, createdAt: NOW, updatedAt: NOW,
    });
    await db.insert(authSchema.organization).values({
      id: ORG, name: ORG, slug: ORG, createdAt: NOW,
    });
    await db.insert(authSchema.member).values({
      id: `${OWNER}-m`, organizationId: ORG, userId: OWNER, role: "owner", createdAt: NOW,
    });
  }

  async function seedSite(gscProperty: string | null = null) {
    await db.insert(domain.sites).values({
      id: SITE, orgId: ORG, ownerUserId: OWNER, name: "Test site",
      primaryLanguage: "fr", gscProperty, createdAt: NOW,
    });
  }

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    authSchema = await import("@/lib/db/auth-schema");
    domain = await import("@/lib/db/domain-schema");
    derive = await import("./derive");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();
    await cleanup();
    await seedBase();
  });

  afterAll(async () => {
    if (!RUN_DB) return;
    await cleanup();
  });

  test("org neuve (owner seul, pas de site) → tout à false", async () => {
    const flags = await derive.deriveActivationFlags(ORG);
    expect(flags).toEqual({
      "add-site": false,
      "connect-gsc": false,
      "import-keywords": false,
      "first-enrichment": false,
      "invite-teammate": false,
    });
  });

  test("site sans GSC → add-site true, connect-gsc false", async () => {
    await seedSite(null);
    const flags = await derive.deriveActivationFlags(ORG);
    expect(flags["add-site"]).toBe(true);
    expect(flags["connect-gsc"]).toBe(false);
  });

  test("site avec gscProperty → connect-gsc true", async () => {
    await seedSite("sc-domain:test.dev");
    const flags = await derive.deriveActivationFlags(ORG);
    expect(flags["connect-gsc"]).toBe(true);
  });

  test("keyword sans export → import-keywords true, first-enrichment false", async () => {
    await seedSite();
    await db.insert(domain.keywords).values({
      id: "act-kw-1", siteId: SITE, term: "audit seo", slug: "audit-seo",
    });
    const flags = await derive.deriveActivationFlags(ORG);
    expect(flags["import-keywords"]).toBe(true);
    expect(flags["first-enrichment"]).toBe(false);
  });

  test("keyword avec enrichment_exports non vide → first-enrichment true", async () => {
    await seedSite();
    await db.insert(domain.keywords).values({
      id: "act-kw-2", siteId: SITE, term: "audit seo", slug: "audit-seo",
      enrichmentExports: { mangools: "2026-06-01" },
    });
    const flags = await derive.deriveActivationFlags(ORG);
    expect(flags["first-enrichment"]).toBe(true);
  });

  test("membre non-owner → invite-teammate true", async () => {
    await db.insert(authSchema.user).values({
      id: STAFF, name: STAFF, email: `${STAFF}@test.dev`,
      emailVerified: true, createdAt: NOW, updatedAt: NOW,
    });
    await db.insert(authSchema.member).values({
      id: `${STAFF}-m`, organizationId: ORG, userId: STAFF, role: "staff", createdAt: NOW,
    });
    const flags = await derive.deriveActivationFlags(ORG);
    expect(flags["invite-teammate"]).toBe(true);
  });

  test("invitation pending seule → invite-teammate true ; owner seul → false", async () => {
    await db.insert(authSchema.invitation).values({
      id: "act-inv-1", organizationId: ORG, email: "new@test.dev", role: "staff",
      status: "pending", expiresAt: new Date(Date.now() + 7 * 86_400_000), inviterId: OWNER,
    });
    const flags = await derive.deriveActivationFlags(ORG);
    expect(flags["invite-teammate"]).toBe(true);
  });

  test("le scoping org est étanche (les faits d'une autre org ne comptent pas)", async () => {
    await seedSite("sc-domain:test.dev"); // ORG a tout côté sites
    const flags = await derive.deriveActivationFlags("act-derive-other-org");
    expect(flags["add-site"]).toBe(false);
    expect(flags["connect-gsc"]).toBe(false);
  });
});
```

NOTE implémenteur : vérifier les colonnes NOT NULL réelles de `sites` et `keywords` dans `src/lib/db/domain-schema.ts` (lignes ~45-176) et compléter les `values({...})` des seeds si d'autres colonnes sont requises (ex. `slug`, `term` — adapter aux vrais noms). Le test doit sémantiquement rester identique.

- [ ] **Step 2: Vérifier l'échec**

Run: `bun run test src/lib/onboarding/activation/derive.test.ts`
Expected: FAIL (`./derive` introuvable).

- [ ] **Step 3: Écrire `derive.ts`**

Requêtes EXISTS (limit 1) — pas de count : c'est appelé à chaque navigation.

```ts
// src/lib/onboarding/activation/derive.ts — complétion dérivée de l'état produit.
// La vérité vit dans la DB ; rien n'est stocké, donc rien ne se désynchronise.
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, sites } from "@/lib/db/domain-schema";
import type { ActivationFlags } from "./types";

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

export async function deriveActivationFlags(orgId: string): Promise<ActivationFlags> {
  const one = { one: sql<number>`1` };

  const [hasSite, hasGsc, hasKeyword, hasEnrichment, hasTeammate] = await Promise.all([
    exists(db.select(one).from(sites).where(eq(sites.orgId, orgId)).limit(1)),
    exists(
      db.select(one).from(sites)
        .where(and(eq(sites.orgId, orgId), isNotNull(sites.gscProperty)))
        .limit(1),
    ),
    exists(
      db.select(one).from(keywords)
        .innerJoin(sites, eq(keywords.siteId, sites.id))
        .where(eq(sites.orgId, orgId))
        .limit(1),
    ),
    exists(
      db.select(one).from(keywords)
        .innerJoin(sites, eq(keywords.siteId, sites.id))
        .where(and(eq(sites.orgId, orgId), sql`${keywords.enrichmentExports} != '{}'::jsonb`))
        .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 {
    "add-site": hasSite,
    "connect-gsc": hasGsc,
    "import-keywords": hasKeyword,
    "first-enrichment": hasEnrichment,
    "invite-teammate": hasTeammate,
  };
}
```

- [ ] **Step 4: Vérifier que le test derive passe**

Run: `bun run test src/lib/onboarding/activation/derive.test.ts`
Expected: PASS (8 tests). Postgres doit tourner (`pg_isready`) et la DB test migrée (`bun run db:migrate:test`).

- [ ] **Step 5: Écrire le test des actions + dismiss (failing)**

```ts
// src/lib/onboarding/activation/actions.test.ts
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";

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

const RUN_DB = process.env.DB_TESTS === "1";
const NOW = new Date();
const ORG = "act-dismiss-org";
const USER = "act-dismiss-user";

describe.skipIf(!RUN_DB)("activation dismissal (postgres)", () => {
  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques de test
  let db: any;
  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques de test
  let authSchema: any;
  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques de test
  let dismiss: any;

  async function cleanup() {
    const { inArray } = await import("drizzle-orm");
    await db.delete(authSchema.organization).where(inArray(authSchema.organization.id, [ORG]));
    await db.delete(authSchema.user).where(inArray(authSchema.user.id, [USER]));
  }

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    authSchema = await import("@/lib/db/auth-schema");
    dismiss = await import("./dismiss");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();
    await cleanup();
    await db.insert(authSchema.user).values({
      id: USER, name: USER, email: `${USER}@test.dev`,
      emailVerified: true, createdAt: NOW, updatedAt: NOW,
    });
    await db.insert(authSchema.organization).values({
      id: ORG, name: ORG, slug: ORG, createdAt: NOW,
    });
  });

  afterAll(async () => {
    if (!RUN_DB) return;
    await cleanup();
  });

  test("dismiss → isDismissed true ; idempotent ; restore → false", async () => {
    expect(await dismiss.isActivationDismissed(USER, ORG)).toBe(false);
    await dismiss.dismissActivation(USER, ORG);
    expect(await dismiss.isActivationDismissed(USER, ORG)).toBe(true);
    await dismiss.dismissActivation(USER, ORG); // pas d'erreur de PK
    await dismiss.restoreActivation(USER, ORG);
    expect(await dismiss.isActivationDismissed(USER, ORG)).toBe(false);
  });

  test("scoping : dismiss (user, orgA) ne masque pas (user, orgB)", async () => {
    await dismiss.dismissActivation(USER, ORG);
    expect(await dismiss.isActivationDismissed(USER, "act-dismiss-other")).toBe(false);
  });
});

// Garde de surface d'endpoint (pattern src/lib/onboarding/actions.test.ts) :
// actions.ts ("use server") ne doit exposer QUE les 3 actions auth-guardées.
test("actions.ts n'exporte que les 3 actions publiques", async () => {
  const mod = await import("./actions");
  expect(Object.keys(mod).sort()).toEqual([
    "dismissActivationAction",
    "getActivationAction",
    "restoreActivationAction",
  ]);
});
```

- [ ] **Step 6: Vérifier l'échec**

Run: `bun run test src/lib/onboarding/activation/actions.test.ts`
Expected: FAIL (`./dismiss` et `./actions` introuvables).

- [ ] **Step 7: Écrire `dismiss.ts`**

```ts
// src/lib/onboarding/activation/dismiss.ts — écritures privilégiées, volontairement
// hors "use server" (chaque export "use server" = endpoint non authentifié).
import "server-only";

import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { activationDismissals } from "@/lib/db/domain-schema";

export async function isActivationDismissed(userId: string, orgId: string): Promise<boolean> {
  const rows = await db
    .select({ userId: activationDismissals.userId })
    .from(activationDismissals)
    .where(and(eq(activationDismissals.userId, userId), eq(activationDismissals.orgId, orgId)))
    .limit(1);
  return rows.length > 0;
}

export async function dismissActivation(userId: string, orgId: string): Promise<void> {
  await db.insert(activationDismissals).values({ userId, orgId }).onConflictDoNothing();
}

export async function restoreActivation(userId: string, orgId: string): Promise<void> {
  await db
    .delete(activationDismissals)
    .where(and(eq(activationDismissals.userId, userId), eq(activationDismissals.orgId, orgId)));
}
```

- [ ] **Step 8: Écrire `actions.ts`**

```ts
// src/lib/onboarding/activation/actions.ts
"use server";

import { getActiveOrgId, getActiveUserId } from "@/lib/active-org";
import { deriveActivationFlags } from "./derive";
import { dismissActivation, isActivationDismissed, restoreActivation } from "./dismiss";
import type { ActivationSnapshot } from "./types";

/** Lecture pure (pas de revalidation) — safe à appeler au mount, pattern
 *  getKeywordQuotaAction. null = pas de session/org active ou erreur :
 *  la carte ne s'affiche pas (fail-silent, l'onboarding ne casse jamais l'app). */
export async function getActivationAction(): Promise<ActivationSnapshot | null> {
  try {
    const userId = await getActiveUserId();
    const orgId = await getActiveOrgId();
    if (await isActivationDismissed(userId, orgId)) {
      return { flags: null, dismissed: true };
    }
    return { flags: await deriveActivationFlags(orgId), dismissed: false };
  } catch (err) {
    console.error("[activation] getActivationAction failed:", err);
    return null;
  }
}

export async function dismissActivationAction(): Promise<void> {
  const userId = await getActiveUserId();
  const orgId = await getActiveOrgId();
  await dismissActivation(userId, orgId);
}

export async function restoreActivationAction(): Promise<void> {
  const userId = await getActiveUserId();
  const orgId = await getActiveOrgId();
  await restoreActivation(userId, orgId);
}
```

- [ ] **Step 9: Vérifier que tout passe**

Run: `bun run test src/lib/onboarding/activation/`
Expected: PASS (steps + derive + actions).

- [ ] **Step 10: Gate + commit**

```bash
bunx tsc --noEmit && bun run lint
git add src/lib/onboarding/activation/
git commit -m "feat(onboarding): activation server layer — derived flags, dismissal, guarded actions"
```

---

### Task 3: Adaptateur moteur OnboardJS

**Files:**
- Create: `src/lib/onboarding/activation/engine.tsx`
- Create: `src/lib/onboarding/activation/engine.test.ts`

**Interfaces:**
- Consumes (Task 1): `ACTIVATION_STEPS`, `firstIncompleteStep` depuis `./steps` ; `ActivationFlags`, `ActivationStepId` depuis `./types`.
- Produces (pour Task 4) — contrat EXACT, quel que soit l'API interne d'OnboardJS :
  ```ts
  export function ActivationEngine(props: {
    flags: ActivationFlags;
    children: React.ReactNode;
  }): React.ReactElement;

  export function useActivationEngine(): {
    /** Première étape incomplète (ordre canonique), null si tout est fait. */
    suggestedStepId: ActivationStepId | null;
  };

  /** Pur, exporté pour test : mappe nos defs vers les steps OnboardJS. */
  export function toEngineSteps(): unknown[];
  ```

- [ ] **Step 1: Lire l'API réelle installée**

OnboardJS est en RC — ne PAS coder de mémoire. Lire les types installés :

```bash
ls node_modules/@onboardjs/react/dist/
sed -n 1,200p node_modules/@onboardjs/react/dist/index.d.ts
```

Repérer : les props exactes de `OnboardingProvider` (steps, initialContext, initial step — nom exact), la forme de `OnboardingStep` (`id`, `type`, `payload`, `isSkippable`, `nextStep`/`previousStep`, `condition`), et ce que retourne `useOnboarding()`. Docs de référence : https://docs.onboardjs.com/react/onboarding-provider et /steps/defining-steps.

- [ ] **Step 2: Écrire le test du mapping (failing)**

On ne teste QUE la partie pure (pas de rendu React sous bun test) :

```ts
// src/lib/onboarding/activation/engine.test.ts
import { describe, expect, it } from "bun:test";
import { toEngineSteps } from "./engine";
import { ACTIVATION_STEP_IDS } from "./types";

describe("toEngineSteps (mapping OnboardJS)", () => {
  it("produit un step moteur par étape d'activation, ids et ordre préservés", () => {
    const steps = toEngineSteps() as Array<{ id: string; isSkippable?: boolean }>;
    expect(steps.map((s) => s.id)).toEqual([...ACTIVATION_STEP_IDS]);
  });

  it("seule invite-teammate est skippable", () => {
    const steps = toEngineSteps() as Array<{ id: string; isSkippable?: boolean }>;
    expect(steps.filter((s) => s.isSkippable).map((s) => s.id)).toEqual([
      "invite-teammate",
    ]);
  });
});
```

- [ ] **Step 3: Vérifier l'échec**

Run: `bun run test src/lib/onboarding/activation/engine.test.ts`
Expected: FAIL (`./engine` introuvable).

- [ ] **Step 4: Écrire `engine.tsx`**

Squelette à adapter aux types réels lus au Step 1 (les noms de props OnboardJS peuvent différer — le contrat EXPORTÉ, lui, ne bouge pas) :

```tsx
// src/lib/onboarding/activation/engine.tsx
// SEUL fichier du repo autorisé à importer @onboardjs/*. Si la lib (RC) meurt,
// casse ou devient payante : on réécrit ce fichier, rien d'autre.
"use client";

import type { OnboardingStep } from "@onboardjs/core";
import { OnboardingProvider, useOnboarding } from "@onboardjs/react";
import type { ReactNode } from "react";
import { ACTIVATION_STEPS, firstIncompleteStep } from "./steps";
import type { ActivationFlags, ActivationStepId } from "./types";

type ActivationContext = { flowData: { flags: ActivationFlags } };

/** Pur, exporté pour test. Chaque étape produit un step moteur ; la navigation
 *  suit l'ordre canonique ; seule l'étape optionnelle est skippable. */
export function toEngineSteps(): OnboardingStep<ActivationContext>[] {
  return ACTIVATION_STEPS.map((def, i) => ({
    id: def.id,
    type: "INFORMATION",
    payload: {},
    isSkippable: !def.required,
    nextStep: ACTIVATION_STEPS[i + 1]?.id ?? null,
    previousStep: ACTIVATION_STEPS[i - 1]?.id ?? null,
  }));
}

export function ActivationEngine({
  flags,
  children,
}: {
  flags: ActivationFlags;
  children: ReactNode;
}) {
  return (
    <OnboardingProvider<ActivationContext>
      steps={toEngineSteps()}
      initialContext={{ flowData: { flags } }}
      // AUCUNE persistance (ni localStoragePersistence ni customOnDataPersist) :
      // l'état est dérivé serveur à chaque fetch, le moteur est jetable.
    >
      {children}
    </OnboardingProvider>
  );
}

export function useActivationEngine(): { suggestedStepId: ActivationStepId | null } {
  const { state } = useOnboarding<ActivationContext>();
  const flags = state?.context?.flowData?.flags;
  return { suggestedStepId: flags ? firstIncompleteStep(flags) : null };
}
```

Si un nom de prop/type diffère dans le `.d.ts` installé, adapter l'INTÉRIEUR du fichier — jamais les exports.

- [ ] **Step 5: Vérifier que le test passe**

Run: `bun run test src/lib/onboarding/activation/engine.test.ts`
Expected: PASS (2 tests).

- [ ] **Step 6: Gate + commit**

```bash
bunx tsc --noEmit && bun run lint
git add src/lib/onboarding/activation/engine.tsx src/lib/onboarding/activation/engine.test.ts
git commit -m "feat(onboarding): OnboardJS engine adapter (single @onboardjs/* touchpoint)"
```

---

### Task 4: UI — carte sidebar, panneau, i18n, restore Settings

**Files:**
- Create: `src/components/onboarding/activation-card.tsx`
- Create: `src/components/onboarding/activation-panel.tsx`
- Modify: `src/components/team-switcher.tsx` (mount au-dessus de `<KeywordQuotaBar />`, ligne ~104)
- Modify: `messages/en.json`, `messages/fr.json`, `messages/es.json`, `messages/de.json` (namespace `Activation`)
- Modify: `src/app/(app)/settings/account/page.tsx` OU le composant client qu'il rend (contrôle « réafficher »)

**Interfaces:**
- Consumes: `getActivationAction`, `dismissActivationAction`, `restoreActivationAction` (Task 2) ; `ACTIVATION_STEPS`, `doneCount`, `requiredComplete` (Task 1) ; `ActivationEngine`, `useActivationEngine` (Task 3) ; `Popover/PopoverTrigger/PopoverContent` de `@/components/ui/popover` (radix) ; `SidebarMenuItem` de `@/components/ui/sidebar` ; `useTranslations` de next-intl.
- Produces: `<ActivationCard />` (client, zéro prop — self-fetching).

- [ ] **Step 1: Ajouter le namespace i18n aux 4 catalogues**

Dans `messages/fr.json` (et l'équivalent traduit dans en/es/de — contenu complet ci-dessous) :

```json
"Activation": {
  "title": "Démarrage",
  "progress": "{done} sur {total}",
  "optional": "Optionnel",
  "done": "Terminé",
  "dismiss": "Masquer la checklist",
  "restoreTitle": "Checklist de démarrage",
  "restoreDescription": "Réaffichez la checklist d'activation dans la barre latérale.",
  "restore": "Réafficher",
  "steps": {
    "add-site": {
      "title": "Ajoutez votre site",
      "description": "Créez votre premier projet : domaine, langue et pays cible.",
      "cta": "Ajouter un site"
    },
    "connect-gsc": {
      "title": "Connectez Search Console",
      "description": "Reliez votre propriété Google Search Console pour vos données réelles.",
      "cta": "Connecter GSC"
    },
    "import-keywords": {
      "title": "Importez vos mots-clés",
      "description": "Importez un CSV ou ajoutez vos premiers mots-clés au réservoir.",
      "cta": "Importer"
    },
    "first-enrichment": {
      "title": "Lancez un export d'enrichissement",
      "description": "Exportez vos mots-clés vers votre outil SEO : volumes et CPC en retour.",
      "cta": "Vers la recherche"
    },
    "invite-teammate": {
      "title": "Invitez un coéquipier",
      "description": "Travaillez à plusieurs sur vos projets SEO.",
      "cta": "Inviter"
    }
  }
}
```

`messages/en.json` :

```json
"Activation": {
  "title": "Getting started",
  "progress": "{done} of {total}",
  "optional": "Optional",
  "done": "Done",
  "dismiss": "Hide checklist",
  "restoreTitle": "Getting-started checklist",
  "restoreDescription": "Show the activation checklist in the sidebar again.",
  "restore": "Show again",
  "steps": {
    "add-site": {
      "title": "Add your site",
      "description": "Create your first project: domain, language and target country.",
      "cta": "Add a site"
    },
    "connect-gsc": {
      "title": "Connect Search Console",
      "description": "Link your Google Search Console property for real data.",
      "cta": "Connect GSC"
    },
    "import-keywords": {
      "title": "Import your keywords",
      "description": "Import a CSV or add your first keywords to the reservoir.",
      "cta": "Import"
    },
    "first-enrichment": {
      "title": "Run an enrichment export",
      "description": "Export your keywords to your SEO tool: volumes and CPC come back.",
      "cta": "Go to research"
    },
    "invite-teammate": {
      "title": "Invite a teammate",
      "description": "Work together on your SEO projects.",
      "cta": "Invite"
    }
  }
}
```

`messages/es.json` :

```json
"Activation": {
  "title": "Primeros pasos",
  "progress": "{done} de {total}",
  "optional": "Opcional",
  "done": "Hecho",
  "dismiss": "Ocultar la lista",
  "restoreTitle": "Lista de primeros pasos",
  "restoreDescription": "Vuelve a mostrar la lista de activación en la barra lateral.",
  "restore": "Mostrar de nuevo",
  "steps": {
    "add-site": {
      "title": "Añade tu sitio",
      "description": "Crea tu primer proyecto: dominio, idioma y país objetivo.",
      "cta": "Añadir un sitio"
    },
    "connect-gsc": {
      "title": "Conecta Search Console",
      "description": "Vincula tu propiedad de Google Search Console para datos reales.",
      "cta": "Conectar GSC"
    },
    "import-keywords": {
      "title": "Importa tus palabras clave",
      "description": "Importa un CSV o añade tus primeras palabras clave al depósito.",
      "cta": "Importar"
    },
    "first-enrichment": {
      "title": "Lanza una exportación de enriquecimiento",
      "description": "Exporta tus palabras clave a tu herramienta SEO: vuelven volúmenes y CPC.",
      "cta": "Ir a investigación"
    },
    "invite-teammate": {
      "title": "Invita a un compañero",
      "description": "Trabajad juntos en vuestros proyectos SEO.",
      "cta": "Invitar"
    }
  }
}
```

`messages/de.json` :

```json
"Activation": {
  "title": "Erste Schritte",
  "progress": "{done} von {total}",
  "optional": "Optional",
  "done": "Erledigt",
  "dismiss": "Checkliste ausblenden",
  "restoreTitle": "Erste-Schritte-Checkliste",
  "restoreDescription": "Aktivierungs-Checkliste wieder in der Seitenleiste anzeigen.",
  "restore": "Wieder anzeigen",
  "steps": {
    "add-site": {
      "title": "Website hinzufügen",
      "description": "Erstelle dein erstes Projekt: Domain, Sprache und Zielland.",
      "cta": "Website hinzufügen"
    },
    "connect-gsc": {
      "title": "Search Console verbinden",
      "description": "Verknüpfe deine Google-Search-Console-Property für echte Daten.",
      "cta": "GSC verbinden"
    },
    "import-keywords": {
      "title": "Keywords importieren",
      "description": "Importiere eine CSV oder füge deine ersten Keywords zum Reservoir hinzu.",
      "cta": "Importieren"
    },
    "first-enrichment": {
      "title": "Anreicherungs-Export starten",
      "description": "Exportiere deine Keywords in dein SEO-Tool: Volumen und CPC kommen zurück.",
      "cta": "Zur Recherche"
    },
    "invite-teammate": {
      "title": "Teammitglied einladen",
      "description": "Arbeitet gemeinsam an euren SEO-Projekten.",
      "cta": "Einladen"
    }
  }
}
```

Vérifier la parité : `bun run test src/i18n/catalogs.test.ts` → PASS.

- [ ] **Step 2: Écrire `activation-panel.tsx`**

```tsx
// src/components/onboarding/activation-panel.tsx
"use client";

import { CheckCircle2, Circle } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { ACTIVATION_STEPS } from "@/lib/onboarding/activation/steps";
import { useActivationEngine } from "@/lib/onboarding/activation/engine";
import type { ActivationFlags } from "@/lib/onboarding/activation/types";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";

export function ActivationPanel({
  flags,
  onDismiss,
}: {
  flags: ActivationFlags;
  onDismiss: () => void;
}) {
  const t = useTranslations("Activation");
  const { suggestedStepId } = useActivationEngine();

  return (
    <div className="flex flex-col gap-1">
      {ACTIVATION_STEPS.map((step) => {
        const isDone = flags[step.id];
        const isSuggested = step.id === suggestedStepId;
        return (
          <div
            key={step.id}
            className={cn(
              "flex items-start gap-2.5 rounded-md p-2",
              isSuggested && "bg-accent",
            )}
          >
            {isDone ? (
              <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-500" />
            ) : (
              <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
            )}
            <div className="min-w-0 flex-1">
              <div className="flex items-center gap-1.5">
                <span
                  className={cn(
                    "text-sm font-medium",
                    isDone && "text-muted-foreground line-through",
                  )}
                >
                  {t(`steps.${step.id}.title`)}
                </span>
                {!step.required && (
                  <span className="rounded bg-muted px-1 text-[10px] text-muted-foreground">
                    {t("optional")}
                  </span>
                )}
              </div>
              {!isDone && (
                <>
                  <p className="mt-0.5 text-xs text-muted-foreground">
                    {t(`steps.${step.id}.description`)}
                  </p>
                  <Button asChild size="sm" variant={isSuggested ? "default" : "outline"} className="mt-1.5 h-7 text-xs">
                    <Link href={step.href}>{t(`steps.${step.id}.cta`)}</Link>
                  </Button>
                </>
              )}
            </div>
          </div>
        );
      })}
      <button
        type="button"
        onClick={onDismiss}
        className="mt-1 self-start px-2 text-xs text-muted-foreground hover:text-foreground"
      >
        {t("dismiss")}
      </button>
    </div>
  );
}
```

- [ ] **Step 3: Écrire `activation-card.tsx`**

Pattern de fetch copié de `src/components/keyword-quota-bar.tsx` (action au mount + refetch sur pathname + focus). ATTENTION mémoire projet « Backlink nav deadlock » : le fetch au mount doit suivre EXACTEMENT le pattern de la quota bar (useEffect avec deps `[pathname, activeSiteId?]`, pas de ref firstRun) — la quota bar est en prod et ne deadlock pas.

```tsx
// src/components/onboarding/activation-card.tsx
"use client";

import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import {
  dismissActivationAction,
  getActivationAction,
} from "@/lib/onboarding/activation/actions";
import { ActivationEngine } from "@/lib/onboarding/activation/engine";
import { doneCount, requiredComplete, ACTIVATION_STEPS } from "@/lib/onboarding/activation/steps";
import type { ActivationSnapshot } from "@/lib/onboarding/activation/types";
import { ActivationPanel } from "./activation-panel";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { SidebarMenuItem } from "@/components/ui/sidebar";

export function ActivationCard() {
  const t = useTranslations("Activation");
  const pathname = usePathname();
  const [snapshot, setSnapshot] = useState<ActivationSnapshot | null>(null);
  const [open, setOpen] = useState(false);

  const refresh = useCallback(() => {
    getActivationAction().then(setSnapshot).catch(() => setSnapshot(null));
  }, []);

  useEffect(() => {
    refresh();
  }, [refresh, pathname]);

  useEffect(() => {
    window.addEventListener("focus", refresh);
    return () => window.removeEventListener("focus", refresh);
  }, [refresh]);

  const flags = snapshot?.flags;
  if (!flags || snapshot?.dismissed || requiredComplete(flags)) return null;

  const done = doneCount(flags);
  const total = ACTIVATION_STEPS.length;
  const pct = Math.round((done / total) * 100);

  const handleDismiss = () => {
    setOpen(false);
    setSnapshot({ flags: null, dismissed: true }); // optimiste
    dismissActivationAction().catch(() => refresh());
  };

  return (
    <SidebarMenuItem className="px-2 pt-1.5 pb-1 group-data-[collapsible=icon]:hidden">
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger asChild>
          <button type="button" className="w-full text-left">
            <div className="flex items-center justify-between text-[11px] text-sidebar-foreground/70">
              <span>{t("title")}</span>
              <span className="tabular-nums">{t("progress", { done, total })}</span>
            </div>
            <div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-sidebar-accent">
              <div
                className="h-full rounded-full bg-emerald-500 transition-all"
                style={{ width: `${pct}%` }}
              />
            </div>
          </button>
        </PopoverTrigger>
        <PopoverContent side="right" align="start" className="w-80 pointer-events-auto">
          <ActivationEngine flags={flags}>
            <ActivationPanel flags={flags} onDismiss={handleDismiss} />
          </ActivationEngine>
        </PopoverContent>
      </Popover>
    </SidebarMenuItem>
  );
}
```

(Le `pointer-events-auto` sur le contenu du popover est le fix connu du gotcha portails radix/base-ui du projet.)

- [ ] **Step 4: Monter la carte dans la sidebar**

Dans `src/components/team-switcher.tsx`, importer `ActivationCard` et la rendre juste AVANT `<KeywordQuotaBar />` (~ligne 104) :

```tsx
import { ActivationCard } from "@/components/onboarding/activation-card";
// ...
<ActivationCard />
<KeywordQuotaBar />
```

- [ ] **Step 5: Contrôle « réafficher » dans Settings → Compte**

Lire `src/app/(app)/settings/account/page.tsx` et le composant client qu'il rend ; y ajouter, en suivant la structure existante (section/carte du même style que les blocs voisins), un bloc `restoreTitle` / `restoreDescription` avec un bouton qui appelle `restoreActivationAction()` puis affiche l'état via re-fetch `getActivationAction()` (bouton désactivé si non-dismissed). Composant client minimal :

```tsx
// dans le composant client de la page account (ou un petit fichier
// src/components/settings/activation-restore.tsx si la page est RSC pure)
"use client";

import { useTranslations } from "next-intl";
import { useEffect, useState, useTransition } from "react";
import {
  getActivationAction,
  restoreActivationAction,
} from "@/lib/onboarding/activation/actions";
import { Button } from "@/components/ui/button";

export function ActivationRestore() {
  const t = useTranslations("Activation");
  const [dismissed, setDismissed] = useState(false);
  const [pending, startTransition] = useTransition();

  useEffect(() => {
    getActivationAction().then((s) => setDismissed(s?.dismissed ?? false));
  }, []);

  if (!dismissed) return null;

  return (
    <div className="flex items-center justify-between gap-4">
      <div>
        <p className="text-sm font-medium">{t("restoreTitle")}</p>
        <p className="text-xs text-muted-foreground">{t("restoreDescription")}</p>
      </div>
      <Button
        variant="outline"
        size="sm"
        disabled={pending}
        onClick={() =>
          startTransition(async () => {
            await restoreActivationAction();
            setDismissed(false);
          })
        }
      >
        {t("restore")}
      </Button>
    </div>
  );
}
```

- [ ] **Step 6: Vérification manuelle rapide**

```bash
bunx tsc --noEmit && bun run lint && bun run test src/i18n/catalogs.test.ts
```

Expected: tout vert. (La QA visuelle live est faite par le contrôleur après merge, pas par cette tâche.)

- [ ] **Step 7: Commit**

```bash
git add src/components/onboarding/ src/components/team-switcher.tsx src/components/settings/ "src/app/(app)/settings/account/" messages/
git commit -m "feat(onboarding): activation checklist UI — sidebar card, panel, settings restore, i18n"
```

---

### Task 5: Gate final (contrôleur, pas de subagent)

- [ ] `bun run test:all` → 0 fail (999+ pass attendus, nouveaux tests inclus)
- [ ] `bunx tsc --noEmit` → 0 erreur ; `bun run lint` → clean
- [ ] QA live : dev server, compte test5 (org quasi vide) → carte visible avec étapes réelles ; org riche (eleva-seo) → carte absente ; dismiss → disparaît ; Settings → Compte → réafficher.
- [ ] ff-merge `feature/activation-checklist` → `dev`, push, suppression de la branche (JAMAIS main).
