# Content Online — Design

- **Date**: 2026-06-06
- **Status**: approved (design)
- **Section**: contenu / online
- **Depends on**: content planning (`content_items`) + keyword GSC overlay (`keywords.gsc`) on `dev`.

## Goal

A per-site **published-content performance table**: every `content_items` row at status `published`, with the real GSC performance (position / clicks / impressions / CTR) of its linked keyword, plus a KPI strip. Real data only — no crawl/indexation data (deferred to a future worker).

## Layout

```
[Publiés][Clics totaux][Impressions][Position moyenne]   ← KPI strip
┌ Contenu en ligne ───────────────────────────────────────────┐
│ Titre        URL        Mot-clé      Pos.  Clics  Impr.  CTR │
│ Guide solaire /blog/…   🇫🇷 panneau  8.0   120    2 100   5.7%│
└──────────────────────────────────────────────────────────────┘
```

## Data model

`src/lib/content/online.ts`:
```ts
export interface OnlineRow {
  id: string;
  title: string;
  url: string | null;
  keywordTerm: string | null;
  keywordCountry: string | null;
  position: number | null;   // keyword.gsc.position
  clicks: number;            // keyword.gsc.clicks (0 if none)
  impressions: number;       // keyword.gsc.impressions (0 if none)
  ctr: number | null;        // keyword.gsc.ctr (0..1), null if no gsc
}

export interface OnlineStats {
  published: number;
  totalClicks: number;
  totalImpressions: number;
  avgPosition: number | null;  // mean over rows that have a position
}

export function buildOnlineStats(rows: OnlineRow[]): OnlineStats;
```
`buildOnlineStats` is pure + tested.

## Flow

Query `getPublishedContentForSite(siteId)` (`src/lib/content/queries.ts`): select `content_items` where `siteId` + `status = "published"`, left-join `keywords`, read the keyword's `gsc` jsonb → map to `OnlineRow[]` (gsc null → position null, clicks/impr 0, ctr null).

Action `getPublishedContentAction(siteId)` (`src/lib/content/actions.ts`) → `OnlineRow[]`.

`OnlineContainer` (client): `useActiveSite` → load the action → `buildOnlineStats(rows)` for the KPI strip + the table; loading skeleton; empty-state when no published content.

## Components / files

```
src/lib/content/online.ts            — OnlineRow + OnlineStats + buildOnlineStats (pure)
src/lib/content/online.test.ts       — unit tests
src/lib/content/queries.ts           — add getPublishedContentForSite
src/lib/content/actions.ts           — add getPublishedContentAction
src/components/content/online/online-container.tsx  — client loader
src/components/content/online/online-kpis.tsx        — 4 StatCards
src/components/content/online/online-table.tsx       — published table
src/app/(app)/content/online/page.tsx                — header + container
src/components/app-sidebar.tsx                        — add "Online" to the Contenu section (after Planning)
```

Reuses `StatCard` (`@/components/keywords/overview/stat-card`), `countryFlag`, shadcn primitives. CSS table (consistent with the opportunities table).

## Error / empty states

- No published content → empty-state ("Aucun contenu publié") + link to `/content/planning`.
- URL null → show "—"; keyword null → no flag/term; no GSC on the keyword → position "—", clicks/impr 0.
- Action failure → non-blocking (fire-and-forget).

## Testing

- **Unit** (`online.test.ts`, pure): `buildOnlineStats` — published count, totalClicks/Impressions sums, avgPosition over rows-with-position (null when none), empty input.
- **Manual (d3k)**: a site with ≥1 published content item whose keyword has GSC → KPIs + table render with real numbers; URL links out; empty-state when none; the Contenu nav has Overview · Planning · Online; `d3k errors` clean.

## Non-goals (YAGNI)

- Crawl/indexation status (no real data; future worker).
- Stats sub-page (thin without GSC history) and import-export.
- Per-page/country GSC dimensions; editing from this table (edit stays on the Planning board sheet).
