# Blog API — Documentation pour l'agent Frontend

Backend : Django 5.1 + DRF — Base URL : `http://localhost:8000`
Tous les endpoints blog sont préfixés par `/api/blog/`.

---

## 1. Concepts clés à connaître AVANT de coder

### 1.1 Bilingue FR / AR
- Toutes les ressources (articles, catégories) ont des traductions stockées dans des **tables séparées** (`ArticleTranslation`, `CategoryTranslation`).
- La langue se choisit via le query param **`?lang=fr`** ou **`?lang=ar`** (défaut : `fr`).
- L'API renvoie **les deux** : un tableau `translations` complet (toutes langues) + des champs « pratiques » (`title`, `excerpt`, `content`, …) déjà localisés selon `?lang=`.
- Pour le switch FR ↔ AR côté front : pas besoin de refetch, le tableau `translations` contient déjà tout. Ou refetch avec le nouveau `lang=` si tu préfères.
- Pour l'AR : pense à `dir="rtl"` sur le conteneur de l'article.

### 1.2 Articles publics vs réservés adhérents (gating)
Chaque article a un booléen `is_adherent` :

| `is_adherent` | Qui voit la **liste** + thumbnail + titre + extrait | Qui voit le **contenu complet** (HTML) |
|---|---|---|
| `false` (public) | Tout le monde | Tout le monde |
| `true` (réservé adhérent) | Tout le monde | **Uniquement** un user authentifié dont `is_membership_valid === true` |

**Comportement serveur** (à reproduire correctement côté UI) :
- Si l'utilisateur n'est **pas éligible** sur un article `is_adherent=true` :
  - `content` est renvoyé en chaîne vide `""`
  - Chaque entrée de `translations[i].content` est aussi vidée (pas de fuite via les autres langues)
  - Le reste (titre, extrait, thumbnail, catégorie, meta SEO) reste visible
- Donc côté front : si `is_adherent && content === ""` → afficher un **paywall / call-to-action** « Réservé aux adhérents — Connectez-vous » / « Renouvelez votre adhésion ».

> ⚠️ Le serveur n'envoie **pas** de flag explicite « tu es éligible ». Tu détectes le gating en testant `is_adherent === true && content === ""`. (Le statut adhérent du user lui-même se lit via `/api/user/dashboard/` ou similaire — voir doc User.)

### 1.3 Statut de publication
Seuls les articles `status === "published"` remontent dans l'API publique. Les brouillons (`draft`) ne sont **jamais** exposés. Pas besoin de filtrer côté front.

---

## 2. Endpoints

### 2.1 `GET /api/blog/articles/` — Liste paginée d'articles

Liste les articles publiés, du plus récent au plus ancien (`-published_date`).

#### Query params

| Param | Type | Défaut | Description |
|---|---|---|---|
| `lang` | `fr` \| `ar` | `fr` | Langue pour les champs localisés |
| `category__slug` | string | — | **Filtre par slug de catégorie** (ex : `?category__slug=economie`) |
| `search` | string | — | Recherche dans `title` et `excerpt` (toutes langues confondues) |
| `ordering` | string | `-published_date` | `published_date`, `-published_date`, `created_at`, `-created_at` |
| `page` | int | `1` | Pagination DRF standard |

#### Exemple

```http
GET /api/blog/articles/?lang=fr&category__slug=economie&search=banque&ordering=-published_date
```

#### Réponse (200)

```json
{
  "count": 42,
  "next": "http://localhost:8000/api/blog/articles/?page=2&lang=fr",
  "previous": null,
  "results": [
    {
      "id": 12,
      "slug": "nouvelle-loi-bancaire-2026",
      "title": "Nouvelle loi bancaire 2026",
      "excerpt": "Résumé court pour la carte…",
      "thumbnail_url": "http://localhost:8000/media/articles/thumbnails/x.jpg",
      "category": {
        "id": 3,
        "slug": "economie",
        "name": "Économie",
        "translations": [
          { "language": "fr", "name": "Économie" },
          { "language": "ar", "name": "اقتصاد" }
        ],
        "created_at": "2026-01-10T09:12:00Z"
      },
      "author_name": "Sami Boulemia",
      "is_pinned": true,
      "is_adherent": false,
      "status": "published",
      "published_date": "2026-05-01T08:00:00Z",
      "created_at": "2026-04-28T14:21:00Z"
    }
  ]
}
```

**Note :** la liste ne contient **jamais** `content` (perf). Pour le HTML complet, taper l'endpoint détail.

---

### 2.2 `GET /api/blog/articles/<slug>/` — Détail d'un article

#### Query params

| Param | Type | Défaut | Description |
|---|---|---|---|
| `lang` | `fr` \| `ar` | `fr` | Langue pour les champs localisés |

#### Exemple

```http
GET /api/blog/articles/nouvelle-loi-bancaire-2026/?lang=ar
```

#### Réponse (200)

```json
{
  "id": 12,
  "slug": "nouvelle-loi-bancaire-2026",
  "title": "قانون بنكي جديد 2026",
  "excerpt": "ملخص…",
  "content": "<p>محتوى HTML كامل…</p>",
  "meta_title": "قانون بنكي جديد 2026",
  "meta_description": "وصف SEO…",
  "keywords": "بنك, قانون, 2026",
  "thumbnail_url": "http://localhost:8000/media/articles/thumbnails/x.jpg",
  "category": { "id": 3, "slug": "economie", "name": "اقتصاد", "translations": [...] },
  "translations": [
    {
      "language": "fr",
      "language_display": "Français",
      "title": "Nouvelle loi bancaire 2026",
      "content": "<p>HTML…</p>",
      "excerpt": "…",
      "meta_title": "…",
      "meta_description": "…",
      "keywords": "banque, loi, 2026"
    },
    {
      "language": "ar",
      "language_display": "العربية",
      "title": "قانون بنكي جديد 2026",
      "content": "<p>HTML…</p>",
      "excerpt": "…",
      "meta_title": "…",
      "meta_description": "…",
      "keywords": "…"
    }
  ],
  "author_name": "Sami Boulemia",
  "is_adherent": false,
  "status": "published",
  "published_date": "2026-05-01T08:00:00Z",
  "created_at": "2026-04-28T14:21:00Z",
  "updated_at": "2026-04-30T10:00:00Z"
}
```

#### Cas particulier : article réservé adhérents + visiteur non éligible

```json
{
  "id": 17,
  "slug": "rapport-interne-q1",
  "title": "Rapport interne Q1",
  "excerpt": "Réservé aux membres…",
  "content": "",
  "translations": [
    { "language": "fr", "title": "Rapport interne Q1", "content": "", "excerpt": "…", ... },
    { "language": "ar", "title": "…", "content": "", "excerpt": "…", ... }
  ],
  "is_adherent": true,
  ...
}
```

→ Côté front : `is_adherent === true && content === ""` ⇒ afficher le **paywall**.

#### Erreurs

- `404` : slug inexistant **ou** article non publié (`draft`).

---

### 2.3 `GET /api/blog/categories/` — Liste des catégories

Pas de pagination, pas de filtres. Tri alphabétique sur le slug.

#### Query params

| Param | Type | Défaut | Description |
|---|---|---|---|
| `lang` | `fr` \| `ar` | `fr` | Langue pour le champ `name` |

#### Réponse (200)

```json
[
  {
    "id": 1,
    "slug": "actualites",
    "name": "Actualités",
    "translations": [
      { "language": "fr", "name": "Actualités" },
      { "language": "ar", "name": "أخبار" }
    ],
    "created_at": "2026-01-01T00:00:00Z"
  },
  {
    "id": 3,
    "slug": "economie",
    "name": "Économie",
    "translations": [...],
    "created_at": "2026-01-10T09:12:00Z"
  }
]
```

**Utilisation typique** : alimenter un menu de filtres / une navbar de catégories qui pointe vers `/articles/?category__slug=<slug>`.

---

### 2.4 `GET /api/blog/pinned/` — Articles épinglés

Articles avec `is_pinned=true` (à mettre en avant sur la home). Mêmes champs que la liste articles.

#### Query params

| Param | Type | Défaut | Description |
|---|---|---|---|
| `lang` | `fr` \| `ar` | `fr` | Langue pour les champs localisés |

#### Réponse (200)

Tableau d'articles au **même format que `/api/blog/articles/` `.results[]`** (mais non paginé).

> ⚠️ **Bug serveur connu** sur cet endpoint — voir section 5.

---

## 3. Patterns de filtre / recherche / pagination — récap

| Besoin UI | Requête |
|---|---|
| Tous les articles, page 1 | `GET /api/blog/articles/?lang=fr` |
| Articles d'une catégorie | `GET /api/blog/articles/?category__slug=economie&lang=fr` |
| Recherche | `GET /api/blog/articles/?search=banque&lang=fr` |
| Recherche dans une catégorie | `GET /api/blog/articles/?category__slug=economie&search=loi&lang=fr` |
| Plus anciens d'abord | `GET /api/blog/articles/?ordering=published_date` |
| Page 2 | `GET /api/blog/articles/?page=2` |
| Home — épinglés | `GET /api/blog/pinned/?lang=fr` |
| Détail (SSR Nuxt) | `GET /api/blog/articles/<slug>/?lang=fr` |

> Le nom exact du paramètre catégorie est **`category__sl​ug`** (deux underscores, c'est du DRF/django-filter standard, pas une typo).

---

## 4. Authentification & gating adhérent côté client

### 4.1 Tokens
- Auth via JWT sur `/api/user/auth/jwt/create/` (custom).
- Le **refresh token** est posé en **cookie HttpOnly** (`refresh_token`) par le serveur ; seul l'**access token** est renvoyé dans le body.
- Pour appeler le blog en authentifié : `Authorization: Bearer <access_token>` + `credentials: "include"` pour que le cookie de refresh suive.

### 4.2 CORS
Le backend autorise `localhost:3000`, `127.0.0.1:3000`, `localhost:8080` avec `credentials: true`. Toujours configurer le client HTTP avec `withCredentials: true` (axios) / `credentials: "include"` (fetch).

### 4.3 Flow conseillé pour un article `is_adherent`
1. Fetch `/api/blog/articles/<slug>/` avec le header `Authorization` si user connecté.
2. Si réponse `is_adherent === true` :
   - `content` non vide → afficher l'article normalement.
   - `content === ""` → afficher l'écran « réservé adhérent » avec CTA :
     - Pas connecté → bouton « Se connecter ».
     - Connecté mais adhésion expirée / non validée → message « Adhésion expirée — renouvelez ».
   - Pour savoir précisément où en est le user, taper l'endpoint dashboard de `User` (voir doc User : `is_membership_valid`, `is_validated_by_staff`, `is_email_verified`).

---

## 5. ⚠️ Bugs serveur connus — à anticiper côté front

Ces points sont documentés dans le `CLAUDE.md` du backend. **Ne pas se baser dessus sans demander confirmation au back avant.**

1. **`GET /api/blog/pinned/` lèvera une 500 (FieldError)** tant que le filtre `pined_article=True` n'est pas corrigé en `is_pinned=True` ([Blog/views.py:109](../Blog/views.py#L109)). → Prévoir un fallback : si 500 sur cet endpoint, masquer la section "épinglés" et logger.
2. **`GET /api/blog/articles/<slug>/` lèvera une 500 (FieldError)** tant que `pined_article` est listé dans `ArticleDetailSerializer.Meta.fields` ([Blog/serializers.py:156](../Blog/serializers.py#L156)). Le détail article est **actuellement cassé**. À corriger côté back avant que le front puisse afficher la page détail.
3. **`author_name`** peut renvoyer `null` ou potentiellement lever une `AttributeError` si l'auteur n'a pas de `get_full_name()` non vide (fallback vers `username` inexistant sur `UserProfile`). Toujours guarder côté front : `author_name || "APECB"`.

> Si tu (l'agent front) vois ces 500 en intégration, **stop et demande au back** de patcher avant de continuer — ne contourne pas en monkey-patchant le front.

---

## 6. Types TypeScript prêts à l'emploi

```ts
export type Language = "fr" | "ar";

export interface CategoryTranslation {
  language: Language;
  name: string;
}

export interface Category {
  id: number;
  slug: string;
  name: string;                       // localisé selon ?lang=
  translations: CategoryTranslation[];
  created_at: string;                 // ISO 8601
}

export interface ArticleTranslation {
  language: Language;
  language_display: string;           // "Français" | "العربية"
  title: string;
  content: string;                    // HTML (vidé si gated)
  excerpt: string;
  meta_title: string;
  meta_description: string;
  keywords: string;
}

export interface ArticleListItem {
  id: number;
  slug: string;
  title: string;                      // localisé
  excerpt: string;                    // localisé
  thumbnail_url: string | null;
  category: Category;
  author_name: string | null;
  is_pinned: boolean;
  is_adherent: boolean;
  status: "published";                // 'draft' jamais exposé
  published_date: string | null;
  created_at: string;
}

export interface ArticleDetail extends Omit<ArticleListItem, "is_pinned"> {
  content: string;                    // "" si gated
  meta_title: string;
  meta_description: string;
  keywords: string;
  translations: ArticleTranslation[];
  updated_at: string;
  // ⚠️ `pined_article` apparaît dans Meta.fields côté back mais le modèle
  // n'a pas ce champ — voir bug §5.2.
}

export interface Paginated<T> {
  count: number;
  next: string | null;
  previous: string | null;
  results: T[];
}

// Helper gating
export function isGated(article: ArticleDetail): boolean {
  return article.is_adherent && article.content === "";
}
```

---

## 7. Exemples d'usage (composition Nuxt 3 / fetch)

### 7.1 Liste avec filtre catégorie
```ts
const { data } = await useFetch<Paginated<ArticleListItem>>(
  "http://localhost:8000/api/blog/articles/",
  {
    query: { lang: locale.value, category__slug: route.params.slug, page: page.value },
    credentials: "include",
  }
);
```

### 7.2 Détail avec gestion paywall
```ts
const { data: article } = await useFetch<ArticleDetail>(
  `http://localhost:8000/api/blog/articles/${slug}/`,
  {
    query: { lang: locale.value },
    headers: token.value ? { Authorization: `Bearer ${token.value}` } : {},
    credentials: "include",
  }
);

const gated = computed(() => article.value && isGated(article.value));
```

### 7.3 Switch de langue sans refetch
Le tableau `translations` contient déjà les deux langues. Pour un changement de langue purement client :
```ts
const current = computed(() =>
  article.value?.translations.find(t => t.language === locale.value)
);
```

---

## 8. SEO

L'article détail expose `meta_title`, `meta_description`, `keywords` (localisés) — à brancher sur `useHead()` / `<NuxtHead>` :

```ts
useHead({
  title: article.value.meta_title || article.value.title,
  meta: [
    { name: "description", content: article.value.meta_description },
    { name: "keywords", content: article.value.keywords },
    { property: "og:title", content: article.value.title },
    { property: "og:description", content: article.value.excerpt },
    { property: "og:image", content: article.value.thumbnail_url ?? "" },
  ],
  htmlAttrs: { lang: locale.value, dir: locale.value === "ar" ? "rtl" : "ltr" },
});
```
