# Projects Management 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:** Add a project-management area (list + per-project settings page with edit & delete) reachable from the sidebar "Projects" section, backed by the real `sites` table.

**Architecture:** Server Components read org-scoped data; mutations go through server actions (`updateSiteAction`, `deleteSiteAction`) validated with the existing `siteInputSchema`. The add-site form is extracted into a shared `SiteForm` used by both the add dialog and the settings page. A `DeleteProjectDialog` (shadcn `AlertDialog`) warns about the keywords/backlinks cascade. The client `SiteContext` gains `updateSite`/`removeSite` so the switcher and sidebar stay in sync.

**Tech Stack:** Next.js 16 (App Router, async `params`), Drizzle/Postgres, Zod, shadcn (+ base-ui comboboxes), bun test, biome.

> ⚠️ Next.js 16 breaking changes — `params` is a `Promise` and must be awaited. Read `node_modules/next/dist/docs/` if a Next API behaves unexpectedly.

---

## File Structure

- `src/lib/sites/types.ts` — **create** — `SiteDetail` (full row type).
- `src/lib/sites/url.ts` — **create** — `SCHEMES`, `cleanDomain`, `splitUrl` (pure URL helpers, moved out of the dialog).
- `src/lib/sites/url.test.ts` — **create** — unit tests for the URL helpers.
- `src/lib/sites/queries.ts` — **modify** — add `getProjectsForOrg`, `getSiteById`.
- `src/lib/sites/actions.ts` — **modify** — add `updateSiteAction`, `deleteSiteAction`.
- `src/lib/sites/actions.test.ts` — **create** — DB-gated integration tests (org scope, update, delete cascade).
- `src/components/site/site-form.tsx` — **create** — shared form body (fields + submit).
- `src/components/site/add-site-dialog.tsx` — **modify** — use `SiteForm`.
- `src/lib/keywords/site-context.tsx` — **modify** — add `updateSite`, `removeSite`.
- `src/components/ui/alert-dialog.tsx` — **create via shadcn skill** — AlertDialog primitive.
- `src/components/site/delete-project-dialog.tsx` — **create** — confirm + cascade warning.
- `src/components/site/add-project-button.tsx` — **create** — client "+ Ajouter" wrapper for the list page.
- `src/components/site/edit-project-form.tsx` — **create** — client edit wrapper around `SiteForm`.
- `src/app/(app)/projects/page.tsx` — **create** — list page.
- `src/app/(app)/projects/[id]/page.tsx` — **create** — settings page.
- `src/components/nav-projects.tsx` — **modify** — real sites + "Gérer les projets".
- `src/components/app-sidebar.tsx` — **modify** — drop demo `projects` data, render `<NavProjects />`.

Verification commands used throughout:
- Typecheck: `npx tsc --noEmit` → expect `TypeScript: No errors found`.
- Lint: `bun run lint` (biome) → expect no errors on touched files.
- Unit tests: `bun test src/lib/sites/url.test.ts`.
- DB tests: `bun run test:db` (needs Postgres test DB migrated; `DB_TESTS=1` gate).
- Live: `d3k agent-browser …` against the running dev server.

---

## Task 1: URL helpers (pure) extracted with tests

**Files:**
- Create: `src/lib/sites/url.ts`
- Test: `src/lib/sites/url.test.ts`

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

```ts
// src/lib/sites/url.test.ts
import { describe, expect, test } from "bun:test";
import { cleanDomain, splitUrl } from "./url";

describe("cleanDomain", () => {
  test("strips scheme, www, trailing slash, lowercases", () => {
    expect(cleanDomain("HTTPS://WWW.Foo.com/")).toBe("foo.com");
    expect(cleanDomain("  http://foo.com  ")).toBe("foo.com");
    expect(cleanDomain("foo.com")).toBe("foo.com");
  });
});

describe("splitUrl", () => {
  test("https + www", () => {
    expect(splitUrl("https://www.foo.com")).toEqual({
      scheme: "https://www.",
      domain: "foo.com",
    });
  });
  test("http no www", () => {
    expect(splitUrl("http://foo.com")).toEqual({
      scheme: "http://",
      domain: "foo.com",
    });
  });
  test("https no www", () => {
    expect(splitUrl("https://foo.com")).toEqual({
      scheme: "https://",
      domain: "foo.com",
    });
  });
  test("round-trips through scheme+domain", () => {
    const { scheme, domain } = splitUrl("https://www.foo.com");
    expect(`${scheme}${domain}`).toBe("https://www.foo.com");
  });
  test("empty url falls back to https://", () => {
    expect(splitUrl("")).toEqual({ scheme: "https://", domain: "" });
  });
});
```

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

Run: `bun test src/lib/sites/url.test.ts`
Expected: FAIL — `Cannot find module './url'`.

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

```ts
// src/lib/sites/url.ts — pure URL helpers shared by the site form & queries.

/** Scheme prefixes offered in the domain dropdown. */
export const SCHEMES = [
  "https://",
  "http://",
  "https://www.",
  "http://www.",
] as const;

/** Strip any pasted scheme / www / trailing slash so only the bare host remains. */
export function cleanDomain(d: string): string {
  return d
    .trim()
    .replace(/^https?:\/\//i, "")
    .replace(/^www\./i, "")
    .replace(/\/+$/, "")
    .toLowerCase();
}

/** Split a stored URL back into a { scheme, domain } pair for form pre-fill. */
export function splitUrl(url: string): { scheme: string; domain: string } {
  const trimmed = url.trim();
  const secure = !/^http:\/\//i.test(trimmed);
  const www = /^https?:\/\/www\./i.test(trimmed);
  const scheme = `${secure ? "https" : "http"}://${www ? "www." : ""}`;
  return { scheme, domain: cleanDomain(trimmed) };
}
```

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

Run: `bun test src/lib/sites/url.test.ts`
Expected: PASS (5 tests).

- [ ] **Step 5: Typecheck & commit**

Run: `npx tsc --noEmit` → expect `No errors`.
```bash
git add src/lib/sites/url.ts src/lib/sites/url.test.ts
git commit -m "feat(sites): extract pure url helpers (cleanDomain, splitUrl) with tests"
```

---

## Task 2: `SiteDetail` type + detail queries

**Files:**
- Create: `src/lib/sites/types.ts`
- Modify: `src/lib/sites/queries.ts`
- Test: `src/lib/sites/actions.test.ts` (created here; reused by Task 3)

- [ ] **Step 1: Create the type**

```ts
// src/lib/sites/types.ts — full project row as exposed to settings UI.
export interface SiteDetail {
  id: string;
  name: string;
  url: string;
  primaryLanguage: string;
  languages: string[];
  targetCountries: string[];
  createdAt: Date;
}
```

- [ ] **Step 2: Add the queries**

Replace the contents of `src/lib/sites/queries.ts` with:

```ts
// src/lib/sites/queries.ts — server-side reads for sites.
import { and, asc, eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { sites as sitesTable } from "@/lib/db/domain-schema";
import type { Site } from "@/lib/keywords/types";
import type { SiteDetail } from "./types";

/** Sites belonging to an org (subtitle uses domain when set). */
export async function getSitesForOrg(orgId: string): Promise<Site[]> {
  const rows = await db
    .select()
    .from(sitesTable)
    .where(eq(sitesTable.orgId, orgId))
    .orderBy(asc(sitesTable.name));
  return rows.map((r) => ({ id: r.id, name: r.name, plan: r.url ?? "" }));
}

type SiteRow = typeof sitesTable.$inferSelect;

function toSiteDetail(r: SiteRow): SiteDetail {
  return {
    id: r.id,
    name: r.name,
    url: r.url ?? "",
    primaryLanguage: r.primaryLanguage,
    languages: r.languages,
    targetCountries: r.targetCountries,
    createdAt: r.createdAt,
  };
}

/** Full project rows for the management list, sorted by name. */
export async function getProjectsForOrg(orgId: string): Promise<SiteDetail[]> {
  const rows = await db
    .select()
    .from(sitesTable)
    .where(eq(sitesTable.orgId, orgId))
    .orderBy(asc(sitesTable.name));
  return rows.map(toSiteDetail);
}

/** A single project, org-scoped. Returns null when id is absent or foreign. */
export async function getSiteById(
  id: string,
  orgId: string,
): Promise<SiteDetail | null> {
  const [row] = await db
    .select()
    .from(sitesTable)
    .where(and(eq(sitesTable.id, id), eq(sitesTable.orgId, orgId)))
    .limit(1);
  return row ? toSiteDetail(row) : null;
}
```

- [ ] **Step 3: Write the DB integration test (queries part)**

```ts
// src/lib/sites/actions.test.ts
// Integration tests — require Postgres + the test DB migrated.
// Skipped unless DB_TESTS=1. Run with: bun run test:db
import { afterAll, beforeEach, describe, expect, test } from "bun:test";

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

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

  const ORG = "demo-org"; // matches getActiveOrgId() stub
  const OTHER_ORG = "other-org";

  beforeEach(async () => {
    ({ db, client } = await import("@/lib/db/client"));
    schema = await import("@/lib/db/domain-schema");
    queries = await import("./queries");
    // clean slate for sites we own in these tests
    await db.delete(schema.sites);
    // seed the two orgs + an owner are assumed present from db:seed.
  });

  afterAll(async () => {
    await client.end();
  });

  async function insertSite(id: string, orgId: string, name: string) {
    await db.insert(schema.sites).values({
      id,
      orgId,
      ownerUserId: "seed-owner",
      name,
      url: "https://example.com",
      primaryLanguage: "fr",
      languages: ["en"],
      targetCountries: ["FR"],
    });
  }

  test("getProjectsForOrg returns only the org's sites, sorted by name", async () => {
    await insertSite("s-b", ORG, "Bravo");
    await insertSite("s-a", ORG, "Alpha");
    await insertSite("s-x", OTHER_ORG, "Foreign");
    const rows = await queries.getProjectsForOrg(ORG);
    expect(rows.map((r) => r.name)).toEqual(["Alpha", "Bravo"]);
  });

  test("getSiteById is org-scoped (null for foreign org)", async () => {
    await insertSite("s-x", OTHER_ORG, "Foreign");
    expect(await queries.getSiteById("s-x", ORG)).toBeNull();
    expect(await queries.getSiteById("s-x", OTHER_ORG)).not.toBeNull();
  });
});
```

> Note: the test assumes `db:seed` has created the `demo-org`/`other-org` organizations and the `seed-owner` user (FK targets). If `other-org` is not seeded, insert it in `beforeEach` via `schema.organization`/`schema.user`, or relax the foreign-org test to use a second seeded org.

- [ ] **Step 4: Run the DB test**

Run: `bun run test:db`
Expected: the two query tests PASS (the action tests added in Task 3 will also live here).

If Postgres test DB is unavailable, run `npx tsc --noEmit` instead and verify the test file typechecks; note the DB run as a follow-up.

- [ ] **Step 5: Commit**

```bash
git add src/lib/sites/types.ts src/lib/sites/queries.ts src/lib/sites/actions.test.ts
git commit -m "feat(sites): SiteDetail type + org-scoped getProjectsForOrg/getSiteById"
```

---

## Task 3: `updateSiteAction` + `deleteSiteAction`

**Files:**
- Modify: `src/lib/sites/actions.ts`
- Test: `src/lib/sites/actions.test.ts` (extend)

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

Add to `src/lib/sites/actions.ts` — update the imports line and append the two actions.

Change the drizzle import at the top of the existing file to include `and`/`eq`:
```ts
import { and, eq } from "drizzle-orm";
```
Append:
```ts
export type MutateSiteResult = { ok: true } | { ok: false; error: string };

/** Update an existing site, scoped to the active org (validated via Zod). */
export async function updateSiteAction(
  id: string,
  raw: SiteInput,
): Promise<MutateSiteResult> {
  const parsed = siteInputSchema.safeParse(raw);
  if (!parsed.success) {
    return {
      ok: false,
      error: parsed.error.issues[0]?.message ?? "Données invalides",
    };
  }
  const orgId = await getActiveOrgId();
  const { name, url, primaryLanguage, languages, targetCountries } =
    parsed.data;
  const updated = await db
    .update(sitesTable)
    .set({ name, url, primaryLanguage, languages, targetCountries })
    .where(and(eq(sitesTable.id, id), eq(sitesTable.orgId, orgId)))
    .returning({ id: sitesTable.id });
  if (updated.length === 0) return { ok: false, error: "Projet introuvable" };
  revalidatePath("/", "layout");
  return { ok: true };
}

/** Delete a site (cascades to its keywords & backlinks), scoped to active org. */
export async function deleteSiteAction(id: string): Promise<MutateSiteResult> {
  const orgId = await getActiveOrgId();
  const deleted = await db
    .delete(sitesTable)
    .where(and(eq(sitesTable.id, id), eq(sitesTable.orgId, orgId)))
    .returning({ id: sitesTable.id });
  if (deleted.length === 0) return { ok: false, error: "Projet introuvable" };
  revalidatePath("/", "layout");
  return { ok: true };
}
```

> The existing file imports `eq`? It currently imports nothing from drizzle. Add the `import { and, eq } from "drizzle-orm";` line near the other imports.

- [ ] **Step 2: Add the action tests to `src/lib/sites/actions.test.ts`**

Inside the same `describe.skipIf(!RUN_DB)` block, add:
```ts
  test("updateSiteAction edits an org-owned site", async () => {
    const actions = await import("./actions");
    await insertSite("s-a", ORG, "Alpha");
    const res = await actions.updateSiteAction("s-a", {
      name: "Alpha Renamed",
      url: "https://alpha.dev",
      primaryLanguage: "en",
      languages: ["fr", "de"],
      targetCountries: ["US", "FR"],
    });
    expect(res).toEqual({ ok: true });
    const row = await queries.getSiteById("s-a", ORG);
    expect(row?.name).toBe("Alpha Renamed");
    expect(row?.primaryLanguage).toBe("en");
    expect(row?.languages).toEqual(["fr", "de"]);
  });

  test("updateSiteAction refuses a foreign-org id", async () => {
    const actions = await import("./actions");
    await insertSite("s-x", OTHER_ORG, "Foreign");
    const res = await actions.updateSiteAction("s-x", {
      name: "Hacked",
      url: "https://hacked.dev",
      primaryLanguage: "fr",
      languages: [],
      targetCountries: [],
    });
    expect(res).toEqual({ ok: false, error: "Projet introuvable" });
  });

  test("deleteSiteAction removes an org-owned site and cascades", async () => {
    const actions = await import("./actions");
    await insertSite("s-a", ORG, "Alpha");
    await db.insert(schema.keywords).values({
      id: "k-1",
      siteId: "s-a",
      term: "test",
      theme: "informational",
      status: "idea",
      primarySource: "manual",
    } as typeof schema.keywords.$inferInsert);
    const res = await actions.deleteSiteAction("s-a");
    expect(res).toEqual({ ok: true });
    expect(await queries.getSiteById("s-a", ORG)).toBeNull();
    const kw = await db
      .select()
      .from(schema.keywords)
      .where(eq(schema.keywords.siteId, "s-a"));
    expect(kw).toHaveLength(0); // cascade
  });

  test("deleteSiteAction refuses a foreign-org id", async () => {
    const actions = await import("./actions");
    await insertSite("s-x", OTHER_ORG, "Foreign");
    const res = await actions.deleteSiteAction("s-x");
    expect(res).toEqual({ ok: false, error: "Projet introuvable" });
  });
```

Add `import { eq } from "drizzle-orm";` at the top of the test file. Adjust the keyword insert literal fields (`theme`/`status`/`primarySource`) to valid enum values from `@/lib/keywords/types` if these differ — open that file to confirm the literal union members before running.

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

Run: `bun run test:db`
Expected: all query + action tests PASS.
Fallback if no DB: `npx tsc --noEmit` (typecheck the action + test files) and note the DB run as follow-up.

- [ ] **Step 4: Commit**

```bash
git add src/lib/sites/actions.ts src/lib/sites/actions.test.ts
git commit -m "feat(sites): updateSiteAction + deleteSiteAction (org-scoped, cascade-aware)"
```

---

## Task 4: Extract shared `SiteForm`, rewire `AddSiteDialog`

**Files:**
- Create: `src/components/site/site-form.tsx`
- Modify: `src/components/site/add-site-dialog.tsx`

- [ ] **Step 1: Create `SiteForm`**

```tsx
// src/components/site/site-form.tsx
"use client";

import { useState } from "react";
import { MultiCombobox } from "@/components/site/multi-combobox";
import { SingleCombobox } from "@/components/site/single-combobox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import type { SiteInput } from "@/lib/sites/schema";
import { COUNTRY_OPTIONS, LANGUAGE_OPTIONS } from "@/lib/sites/options";
import { cleanDomain, SCHEMES } from "@/lib/sites/url";

export interface SiteFormValues {
  name: string;
  scheme: string;
  domain: string;
  primaryLanguage: string;
  languages: string[];
  targetCountries: string[];
}

const EMPTY: SiteFormValues = {
  name: "",
  scheme: SCHEMES[0],
  domain: "",
  primaryLanguage: "fr",
  languages: [],
  targetCountries: [],
};

function Field({
  label,
  children,
}: {
  label: string;
  children: React.ReactNode;
}) {
  return (
    <label className="grid gap-1.5">
      <span className="font-medium text-sm">{label}</span>
      {children}
    </label>
  );
}

export function SiteForm({
  defaultValues,
  submitLabel,
  pending,
  error,
  onSubmit,
}: {
  defaultValues?: Partial<SiteFormValues>;
  submitLabel: string;
  pending: boolean;
  error: string | null;
  onSubmit: (input: SiteInput) => void;
}) {
  const init = { ...EMPTY, ...defaultValues };
  const [name, setName] = useState(init.name);
  const [scheme, setScheme] = useState(init.scheme);
  const [domain, setDomain] = useState(init.domain);
  const [primaryLanguage, setPrimaryLanguage] = useState(init.primaryLanguage);
  const [languages, setLanguages] = useState<string[]>(init.languages);
  const [countries, setCountries] = useState<string[]>(init.targetCountries);

  function submit() {
    if (pending) return;
    onSubmit({
      name,
      url: `${scheme}${cleanDomain(domain)}`,
      primaryLanguage,
      languages,
      targetCountries: countries,
    });
  }

  return (
    <div className="grid gap-4">
      <Field label="Nom">
        <Input
          value={name}
          onChange={(e) => setName(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter") {
              e.preventDefault();
              submit();
            }
          }}
          placeholder="Atelier Forge"
          aria-label="Nom du site"
        />
      </Field>

      <Field label="Domaine">
        <div className="flex items-center gap-2">
          <Select value={scheme} onValueChange={setScheme}>
            <SelectTrigger className="w-36 shrink-0" aria-label="Schéma">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {SCHEMES.map((s) => (
                <SelectItem key={s} value={s}>
                  {s}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          <Input
            value={domain}
            onChange={(e) => setDomain(e.target.value)}
            placeholder="atelierforge.com"
            aria-label="Domaine"
            className="flex-1"
          />
        </div>
      </Field>

      <Field label="Langue principale">
        <SingleCombobox
          options={LANGUAGE_OPTIONS}
          value={primaryLanguage}
          onValueChange={(next) => {
            setPrimaryLanguage(next);
            setLanguages((prev) => prev.filter((l) => l !== next));
          }}
          ariaLabel="Langue principale"
          placeholder="Rechercher une langue…"
        />
      </Field>

      <Field label="Langues additionnelles (i18n)">
        <MultiCombobox
          options={LANGUAGE_OPTIONS.filter((l) => l.value !== primaryLanguage)}
          value={languages}
          onValueChange={setLanguages}
          placeholder="Ajouter une langue…"
        />
      </Field>

      <Field label="Pays cible">
        <MultiCombobox
          options={COUNTRY_OPTIONS}
          value={countries}
          onValueChange={setCountries}
          placeholder="Ajouter un pays…"
        />
      </Field>

      {error ? (
        <p className="text-destructive text-sm" role="alert">
          {error}
        </p>
      ) : null}

      <div className="flex justify-end">
        <Button
          onClick={submit}
          disabled={!name.trim() || !cleanDomain(domain) || pending}
        >
          {pending ? "Enregistrement…" : submitLabel}
        </Button>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Rewrite `AddSiteDialog` to use `SiteForm`**

```tsx
// src/components/site/add-site-dialog.tsx
"use client";

import { useState } from "react";
import { SiteForm } from "@/components/site/site-form";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import type { Site } from "@/lib/keywords/types";
import { createSiteAction } from "@/lib/sites/actions";
import type { SiteInput } from "@/lib/sites/schema";

export function AddSiteDialog({
  open,
  onOpenChange,
  onCreated,
}: {
  open: boolean;
  onOpenChange: (o: boolean) => void;
  onCreated: (site: Site) => void;
}) {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(input: SiteInput) {
    setError(null);
    setPending(true);
    try {
      const result = await createSiteAction(input);
      if (!result.ok) {
        setError(result.error);
        return;
      }
      onCreated(result.site);
      onOpenChange(false);
    } finally {
      setPending(false);
    }
  }

  return (
    <Dialog
      open={open}
      onOpenChange={(o) => {
        if (!o) setError(null);
        onOpenChange(o);
      }}
    >
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Nouveau site / projet</DialogTitle>
          <DialogDescription>
            Le domaine sur lequel tu travailles (keywords, backlinks scopés
            dessus).
          </DialogDescription>
        </DialogHeader>
        <SiteForm
          submitLabel="Créer le site"
          pending={pending}
          error={error}
          onSubmit={handleSubmit}
        />
      </DialogContent>
    </Dialog>
  );
}
```

> The dialog content unmounts on close (radix default), so `SiteForm` state resets between opens — no explicit reset needed.

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

Run: `npx tsc --noEmit` → expect no errors.
Run: `bun run lint` → expect clean.

- [ ] **Step 4: Live verification — add-site still works**

```bash
d3k agent-browser open http://localhost:3000/dashboard
# open the team switcher → "Add site", fill Nom + Domaine, pick languages, click "Créer le site"
d3k logs --type server -n 5 | grep createSiteAction   # expect a payload with the fields
d3k errors -n 5                                        # expect no new errors
```
Expected: the create flow behaves exactly as before the refactor (chips work, site created).

- [ ] **Step 5: Commit**

```bash
git add src/components/site/site-form.tsx src/components/site/add-site-dialog.tsx
git commit -m "refactor(sites): extract shared SiteForm, reuse it in AddSiteDialog"
```

---

## Task 5: Extend `SiteContext` with `updateSite` / `removeSite`

**Files:**
- Modify: `src/lib/keywords/site-context.tsx`

- [ ] **Step 1: Add the two methods + interface fields**

In `src/lib/keywords/site-context.tsx`:

Extend the interface:
```ts
interface SiteContextValue {
  activeSiteId: string;
  activeSite: Site | undefined;
  setActiveSiteId: (id: string) => void;
  addSite: (site: Site) => void;
  updateSite: (site: Site) => void;
  removeSite: (id: string) => void;
  sites: Site[];
}
```

Add the functions inside `SiteProvider` (after `addSite`):
```ts
  function updateSite(site: Site) {
    setSites((prev) => prev.map((s) => (s.id === site.id ? site : s)));
  }

  function removeSite(id: string) {
    setSites((prev) => prev.filter((s) => s.id !== id));
    // "" makes activeSite fall back to the first remaining site
    setActiveSiteId((cur) => (cur === id ? "" : cur));
  }
```

Add them to the context value object:
```ts
      value={{
        activeSiteId: activeSite?.id ?? "",
        activeSite,
        setActiveSiteId,
        addSite,
        updateSite,
        removeSite,
        sites,
      }}
```

- [ ] **Step 2: Typecheck & commit**

Run: `npx tsc --noEmit` → expect no errors.
```bash
git add src/lib/keywords/site-context.tsx
git commit -m "feat(sites): SiteContext updateSite/removeSite (keep switcher in sync)"
```

---

## Task 6: AlertDialog primitive (via shadcn skill)

**Files:**
- Create: `src/components/ui/alert-dialog.tsx`

- [ ] **Step 1: Add the component via the shadcn skill**

Invoke the `shadcn` Skill (project rule: never hand-write shadcn components). Ask it to add `alert-dialog`. It will resolve the correct registry command, e.g.:
```bash
bunx shadcn@latest add alert-dialog
```
Verify the file exists and exports `AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel`.

- [ ] **Step 2: Typecheck & commit**

Run: `npx tsc --noEmit` → expect no errors.
```bash
git add src/components/ui/alert-dialog.tsx components.json package.json bun.lock
git commit -m "chore(ui): add shadcn alert-dialog"
```
(Commit only the files the skill actually created/changed.)

---

## Task 7: `DeleteProjectDialog`

**Files:**
- Create: `src/components/site/delete-project-dialog.tsx`

- [ ] **Step 1: Create the dialog**

```tsx
// src/components/site/delete-project-dialog.tsx
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import {
  AlertDialog,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { useActiveSite } from "@/lib/keywords/site-context";
import { deleteSiteAction } from "@/lib/sites/actions";

export function DeleteProjectDialog({
  id,
  name,
}: {
  id: string;
  name: string;
}) {
  const { removeSite } = useActiveSite();
  const router = useRouter();
  const [open, setOpen] = useState(false);
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleDelete() {
    setError(null);
    setPending(true);
    try {
      const result = await deleteSiteAction(id);
      if (!result.ok) {
        setError(result.error);
        setPending(false);
        return;
      }
      removeSite(id);
      router.push("/projects");
    } catch {
      setError("Échec de la suppression");
      setPending(false);
    }
  }

  return (
    <AlertDialog open={open} onOpenChange={setOpen}>
      <AlertDialogTrigger asChild>
        <Button variant="destructive">Supprimer le projet</Button>
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Supprimer « {name} » ?</AlertDialogTitle>
          <AlertDialogDescription>
            Cela supprime aussi <strong>tous ses keywords</strong> et{" "}
            <strong>tous ses backlinks</strong>. Action irréversible.
          </AlertDialogDescription>
        </AlertDialogHeader>
        {error ? (
          <p className="text-destructive text-sm" role="alert">
            {error}
          </p>
        ) : null}
        <AlertDialogFooter>
          <AlertDialogCancel disabled={pending}>Annuler</AlertDialogCancel>
          <Button
            variant="destructive"
            onClick={handleDelete}
            disabled={pending}
          >
            {pending ? "Suppression…" : "Supprimer"}
          </Button>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}
```

> We render a plain `Button` (not `AlertDialogAction`) for the destructive action so the dialog does not auto-close before the async delete + redirect completes.

- [ ] **Step 2: Typecheck & commit**

Run: `npx tsc --noEmit` → expect no errors.
```bash
git add src/components/site/delete-project-dialog.tsx
git commit -m "feat(projects): DeleteProjectDialog with cascade warning"
```

---

## Task 8: `EditProjectForm` (client wrapper) + `AddProjectButton`

**Files:**
- Create: `src/components/site/edit-project-form.tsx`
- Create: `src/components/site/add-project-button.tsx`

- [ ] **Step 1: Create `EditProjectForm`**

```tsx
// src/components/site/edit-project-form.tsx
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import { SiteForm } from "@/components/site/site-form";
import { useActiveSite } from "@/lib/keywords/site-context";
import { updateSiteAction } from "@/lib/sites/actions";
import type { SiteInput } from "@/lib/sites/schema";
import type { SiteDetail } from "@/lib/sites/types";
import { splitUrl } from "@/lib/sites/url";

export function EditProjectForm({ site }: { site: SiteDetail }) {
  const { updateSite } = useActiveSite();
  const router = useRouter();
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [saved, setSaved] = useState(false);
  const { scheme, domain } = splitUrl(site.url);

  async function handleSubmit(input: SiteInput) {
    setError(null);
    setSaved(false);
    setPending(true);
    try {
      const result = await updateSiteAction(site.id, input);
      if (!result.ok) {
        setError(result.error);
        return;
      }
      updateSite({ id: site.id, name: input.name, plan: input.url });
      setSaved(true);
      router.refresh();
    } finally {
      setPending(false);
    }
  }

  return (
    <div className="grid gap-3">
      <SiteForm
        defaultValues={{
          name: site.name,
          scheme,
          domain,
          primaryLanguage: site.primaryLanguage,
          languages: site.languages,
          targetCountries: site.targetCountries,
        }}
        submitLabel="Enregistrer"
        pending={pending}
        error={error}
        onSubmit={handleSubmit}
      />
      {saved ? (
        <p className="text-muted-foreground text-sm" role="status">
          Modifications enregistrées.
        </p>
      ) : null}
    </div>
  );
}
```

- [ ] **Step 2: Create `AddProjectButton`**

```tsx
// src/components/site/add-project-button.tsx
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import { AddSiteDialog } from "@/components/site/add-site-dialog";
import { Button } from "@/components/ui/button";
import { useActiveSite } from "@/lib/keywords/site-context";
import type { Site } from "@/lib/keywords/types";

export function AddProjectButton() {
  const { addSite } = useActiveSite();
  const router = useRouter();
  const [open, setOpen] = useState(false);

  function handleCreated(site: Site) {
    addSite(site);
    router.refresh(); // surface the new row in the server-rendered list
  }

  return (
    <>
      <Button onClick={() => setOpen(true)}>+ Ajouter</Button>
      <AddSiteDialog
        open={open}
        onOpenChange={setOpen}
        onCreated={handleCreated}
      />
    </>
  );
}
```

- [ ] **Step 3: Typecheck & commit**

Run: `npx tsc --noEmit` → expect no errors.
```bash
git add src/components/site/edit-project-form.tsx src/components/site/add-project-button.tsx
git commit -m "feat(projects): edit form wrapper + add-project button"
```

---

## Task 9: `/projects` list page

**Files:**
- Create: `src/app/(app)/projects/page.tsx`

- [ ] **Step 1: Create the page**

```tsx
// src/app/(app)/projects/page.tsx
import Link from "next/link";
import { AddProjectButton } from "@/components/site/add-project-button";
import { getActiveOrgId } from "@/lib/active-org";
import { COUNTRY_OPTIONS, LANGUAGE_OPTIONS } from "@/lib/sites/options";
import { getProjectsForOrg } from "@/lib/sites/queries";

function labelFor(options: { value: string; label: string }[], code: string) {
  return options.find((o) => o.value === code)?.label ?? code;
}

export default async function ProjectsPage() {
  const orgId = await getActiveOrgId();
  const projects = await getProjectsForOrg(orgId);

  return (
    <div className="mx-auto w-full max-w-3xl p-6">
      <div className="mb-6 flex items-center justify-between">
        <h1 className="font-semibold text-xl">Projets</h1>
        <AddProjectButton />
      </div>

      {projects.length === 0 ? (
        <p className="text-muted-foreground text-sm">
          Aucun projet pour l'instant. Ajoute ton premier site.
        </p>
      ) : (
        <ul className="divide-y rounded-lg border">
          {projects.map((p) => (
            <li key={p.id}>
              <Link
                href={`/projects/${p.id}`}
                className="flex items-center justify-between gap-4 p-4 hover:bg-muted/50"
              >
                <div className="grid gap-1">
                  <span className="font-medium">{p.name}</span>
                  <span className="text-muted-foreground text-sm">
                    {p.url}
                  </span>
                  <span className="text-xs">
                    {labelFor(LANGUAGE_OPTIONS, p.primaryLanguage)}
                    {p.languages.length
                      ? ` · +${p.languages.length} langue(s)`
                      : ""}
                    {p.targetCountries.length
                      ? ` · ${p.targetCountries
                          .map((c) => labelFor(COUNTRY_OPTIONS, c))
                          .join(", ")}`
                      : ""}
                  </span>
                </div>
                <span aria-hidden className="text-muted-foreground">
                  →
                </span>
              </Link>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
```

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

Run: `npx tsc --noEmit` → expect no errors.
Run: `bun run lint` → expect clean.

- [ ] **Step 3: Live verification**

```bash
d3k agent-browser open http://localhost:3000/projects
d3k agent-browser snapshot -i     # expect "Projets" heading, "+ Ajouter", project rows
d3k errors -n 5                   # expect no new errors
```
Expected: existing org sites are listed; rows link to `/projects/<id>`; "+ Ajouter" opens the dialog and a new project appears after creation.

- [ ] **Step 4: Commit**

```bash
git add "src/app/(app)/projects/page.tsx"
git commit -m "feat(projects): /projects list page with add button"
```

---

## Task 10: `/projects/[id]` settings page

**Files:**
- Create: `src/app/(app)/projects/[id]/page.tsx`

- [ ] **Step 1: Create the page**

```tsx
// src/app/(app)/projects/[id]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { DeleteProjectDialog } from "@/components/site/delete-project-dialog";
import { EditProjectForm } from "@/components/site/edit-project-form";
import { getActiveOrgId } from "@/lib/active-org";
import { getSiteById } from "@/lib/sites/queries";

export default async function ProjectSettingsPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params; // Next 16: params is a Promise
  const orgId = await getActiveOrgId();
  const site = await getSiteById(id, orgId);
  if (!site) notFound();

  return (
    <div className="mx-auto w-full max-w-2xl p-6">
      <Link
        href="/projects"
        className="text-muted-foreground text-sm hover:underline"
      >
        ← Projets
      </Link>
      <h1 className="mt-2 mb-6 font-semibold text-xl">
        {site.name} <span className="text-muted-foreground">/ Paramètres</span>
      </h1>

      <EditProjectForm site={site} />

      <div className="mt-10 rounded-lg border border-destructive/40 p-4">
        <h2 className="font-medium text-destructive text-sm">Zone de danger</h2>
        <p className="mt-1 mb-3 text-muted-foreground text-sm">
          Supprimer ce projet supprime aussi ses keywords et ses backlinks.
        </p>
        <DeleteProjectDialog id={site.id} name={site.name} />
      </div>
    </div>
  );
}
```

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

Run: `npx tsc --noEmit` → expect no errors.
Run: `bun run lint` → expect clean.

- [ ] **Step 3: Live verification — edit + delete**

```bash
# from /projects, click a project row → /projects/<id>
d3k agent-browser open http://localhost:3000/projects
d3k agent-browser snapshot -i
# (click first row) then verify the settings form is pre-filled:
d3k agent-browser snapshot -i      # expect Nom/Domaine/Langue pre-filled, "Enregistrer", "Supprimer le projet"
# edit the name, click Enregistrer:
d3k logs --type server -n 5 | grep updateSiteAction   # expect ok
# delete: click "Supprimer le projet" → confirm dialog → "Supprimer"
d3k logs --type server -n 5 | grep deleteSiteAction   # expect ok, redirect to /projects
d3k errors -n 5
```
Expected: edit persists (server log shows `updateSiteAction`, switcher name updates); delete removes the project, redirects to `/projects`, and if it was the active site the switcher falls back to another.

- [ ] **Step 4: Commit**

```bash
git add "src/app/(app)/projects/[id]/page.tsx"
git commit -m "feat(projects): /projects/[id] settings page (edit + danger zone)"
```

---

## Task 11: Sidebar — real projects + "Gérer les projets"

**Files:**
- Modify: `src/components/nav-projects.tsx`
- Modify: `src/components/app-sidebar.tsx`

- [ ] **Step 1: Rewrite `NavProjects` to consume real sites**

```tsx
// src/components/nav-projects.tsx
"use client";

import Link from "next/link";
import {
  FolderIcon,
  MoreHorizontalIcon,
  SettingsIcon,
  TargetIcon,
} from "lucide-react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  SidebarGroup,
  SidebarGroupLabel,
  SidebarMenu,
  SidebarMenuAction,
  SidebarMenuButton,
  SidebarMenuItem,
  useSidebar,
} from "@/components/ui/sidebar";
import { useActiveSite } from "@/lib/keywords/site-context";

export function NavProjects() {
  const { isMobile } = useSidebar();
  const { sites, setActiveSiteId } = useActiveSite();

  return (
    <SidebarGroup className="group-data-[collapsible=icon]:hidden">
      <SidebarGroupLabel>Projects</SidebarGroupLabel>
      <SidebarMenu>
        {sites.map((site) => (
          <SidebarMenuItem key={site.id}>
            <SidebarMenuButton asChild>
              <Link href={`/projects/${site.id}`}>
                <FolderIcon />
                <span>{site.name}</span>
              </Link>
            </SidebarMenuButton>
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <SidebarMenuAction showOnHover className="aria-expanded:bg-muted">
                  <MoreHorizontalIcon />
                  <span className="sr-only">Actions</span>
                </SidebarMenuAction>
              </DropdownMenuTrigger>
              <DropdownMenuContent
                className="w-fit"
                side={isMobile ? "bottom" : "right"}
                align={isMobile ? "end" : "start"}
              >
                <DropdownMenuItem asChild>
                  <Link href={`/projects/${site.id}`}>
                    <SettingsIcon />
                    <span>Paramètres</span>
                  </Link>
                </DropdownMenuItem>
                <DropdownMenuItem onSelect={() => setActiveSiteId(site.id)}>
                  <TargetIcon />
                  <span>Définir comme actif</span>
                </DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </SidebarMenuItem>
        ))}
        <SidebarMenuItem>
          <SidebarMenuButton asChild className="text-sidebar-foreground/70">
            <Link href="/projects">
              <MoreHorizontalIcon className="text-sidebar-foreground/70" />
              <span>Gérer les projets</span>
            </Link>
          </SidebarMenuButton>
        </SidebarMenuItem>
      </SidebarMenu>
    </SidebarGroup>
  );
}
```

> Icons use the `*Icon`-suffixed lucide-react exports (matching the codebase convention). If `TargetIcon` is missing in the installed version, swap for `CircleCheckIcon`; the typecheck step will catch an unresolved import.

- [ ] **Step 2: Drop demo data in `app-sidebar.tsx`**

In `src/components/app-sidebar.tsx`:
- Remove the `projects: [...]` array from the `data` object.
- Remove now-unused icon imports `FrameIcon`, `PieChartIcon`, `MapIcon`.
- Change the render from `<NavProjects projects={data.projects} />` to `<NavProjects />`.

Resulting render block:
```tsx
      <SidebarContent>
        <NavMain items={data.navMain} />
        <NavProjects />
      </SidebarContent>
```

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

Run: `npx tsc --noEmit` → expect no errors (catches the removed-prop/unused-import mismatches).
Run: `bun run lint` → expect clean.

- [ ] **Step 4: Live verification**

```bash
d3k agent-browser open http://localhost:3000/dashboard
d3k agent-browser snapshot -i    # "Projects" group lists the real org sites + "Gérer les projets"
# click a project → navigates to /projects/<id>
# open ⋯ → "Paramètres" (navigates) and "Définir comme actif" (switcher updates)
d3k errors -n 5
```
Expected: the sidebar "Projects" section shows the real sites; "Gérer les projets" opens `/projects`; ⋯ actions work.

- [ ] **Step 5: Commit**

```bash
git add src/components/nav-projects.tsx src/components/app-sidebar.tsx
git commit -m "feat(projects): wire sidebar Projects to real sites + manage link"
```

---

## Final verification

- [ ] `npx tsc --noEmit` → `No errors`.
- [ ] `bun run lint` → clean.
- [ ] `bun test src/lib/sites/url.test.ts` → PASS.
- [ ] `bun run test:db` → PASS (or noted as follow-up if no Postgres).
- [ ] Live end-to-end (d3k): create → list shows it → open settings → edit name (switcher updates) → delete (cascade warning → redirect, active site reassigned).
- [ ] `d3k errors` → no new app errors (the `/avatars/shadcn.jpg` 404 is pre-existing and unrelated).
