# CHANGELOG — Module Rich Snippets

## [4.2.0] — 2026-05-13 — Fix JSON-LD LocalBusiness Zone d'intervention + adresse/prix siège social

### Corrigé

#### JSON-LD — Mode Zone d'intervention (`$linked_agency = null`)
- `LocalBusiness.name` — utilisait le titre de la page WP (`get_the_title()`) → désormais `$org_name` (`get_bloginfo('name')`) quand aucune agence n'est liée. Évitait "Accueil" en JSON-LD.
- `LocalBusiness.image` — fallback vers le logo du site (`nexxis_rs_get_logo()`) si aucune image à la une. Élimine l'avertissement Google Rich Results "image manquante".
- `LocalBusiness.address` — priorité `nrs_get_org_address()` sur le miroir GMB (`nexxis_rs_get_address()`), vide en l'absence d'agences. Voir [ADR-004](../../../docs/decisions/ADR-004-org-address-zone-intervention.md).
- `LocalBusiness.priceRange` — nouvelle option globale `org_price_range` (fallback sur `gmb_price_range`). `gmb_price_range` n'était pas modifiable via formulaire ; `org_price_range` est un vrai champ select admin.

### Ajouté

#### Nouvelles options — Identité juridique
- `org_address` (PostalAddress) dans `helpers.php` defaults + fonction `nrs_get_org_address()` : renvoie un tableau schema.org `PostalAddress` ou `null` si non renseigné.
- `org_price_range` dans `helpers.php` defaults.
- Section "Adresse du siège social" dans `admin-ui.php` : 5 champs inline (rue, ville, CP, région, pays) avec auto-fill depuis le pick INPI/SIREN.
- Champ "Fourchette de prix" dans `admin-ui.php` : select €/€€/€€€/€€€€.
- `nrsPopulateGlobalIdentity(displayData, addressData)` — 2e paramètre `addressData` auto-remplit les champs `org_address` lors du pick INPI.

---

## [4.1.0] — 2026-05-12 — Validation schema.org officielle + géocodage CI + fix suggest-pick

### Ajouté

#### Validation des types schema.org depuis la source officielle
- `nrs_fetch_and_cache_schema_types()` dans `helpers.php` — télécharge le vocabulaire JSON-LD officiel (`schema.org/version/latest/schemaorg-current-https.jsonld`), parse les 932 `rdfs:Class` avec préfixe `schema:`, stocke dans `wp_option('nrs_schema_types_cache')` (autoload=false). Garde-fou : refus si < 100 types parsés.
- `nrs_valid_schema_types()` modifié — lit le cache option en priorité, fallback liste statique embarquée si cache absent ou trop petit.
- Cron mensuel `nexxis_rs_cron_refresh_schema_types` dans `class-rich-snippets.php` — maintien automatique du vocabulaire à jour.
- Bouton admin "🔄 Types schema.org" + statut (compteur + date de sync) — refresh manuel possible depuis la page Toolkit.
- Handler AJAX `wp_ajax_nrs_refresh_schema_types`.

#### Validation douce pour les types CamelCase non référencés
- `nrs_filter_valid_schema_types()` — nouveau bucket `unknown` (CamelCase valide mais absent du vocab courant) en plus de `valid` et `rejected`. Voir [ADR-002](../../../docs/decisions/ADR-002-soft-validation-unknown-schema-types.md).
- Suggestions `unknown` affichées en jaune ⚠️ cliquable dans l'admin (non bloquantes, tooltip lien schema.org).

#### Géocodage pays non-FR
- `nrs_resolve_api_type()` — bascule automatique sur `gplaces` si `country ≠ FR` et clé disponible (évite la configuration manuelle du type d'API par pays).

### Corrigé

#### Géocodage Côte d'Ivoire
- `nexxis_rs_fetch_city_details_api()` — filtre `country` désormais dynamique (était codé en dur `FR` — bloquait toutes les villes hors France). Utilise `nrs_get_gplaces_key()` au lieu de lire la clé depuis `$opts`.
- 18/18 villes ivoiriennes géocodées après correction + purge des transients + reconfiguration IP clé API. Voir [ADR-003](../../../docs/decisions/ADR-003-google-api-ip-outbound-php.md).

#### UX — Champ Types Schema.org
- Pick handler JS — filtre les valeurs non-CamelCase déjà présentes dans le champ avant d'ajouter un type suggéré. Évite le cas "carrière de pierre, WholesaleStore" quand l'utilisateur avait saisi du texte libre dans le champ avant de cliquer "Suggérer via IA".

#### IA — Fin des hallucinations de types
- System prompt OpenAI pour `nrs_agency_suggest_types` — injecte la liste complète `nrs_valid_schema_types()` (~932 types après sync) avec règle stricte "choix EXCLUSIF parmi cette liste". Résout les suggestions `BuildingMaterialStore`, `Supplier`, `ConstructionCompany` (tous absents de schema.org).

---

## [4.0.0] — 2026-04-23 — Permissions strictes : 2 niveaux d'accès (Admin / Editor)

### Ajouté — 2 helpers de permissions dans `helpers.php`

#### `nrs_user_is_module_admin()` — accès **strict admin**
Critères combinés (TOUS requis) :
1. Utilisateur connecté
2. Capability `manage_options`
3. **Rôle `administrator`** explicite (ou Super Admin sur multisite)

Le double check rôle + capability protège contre les plugins de gestion de rôles (User Role Editor, Members…) qui distribueraient `manage_options` à un rôle Editor custom.

#### `nrs_user_can_edit_schema()` — accès **Editor + Admin**
Critères : utilisateur connecté + capability `edit_posts`. Couvre les Editors qui rédigent et structurent leurs propres pages (FAQ, description SEO, choix du @type Schema.org, vérification du JSON-LD émis).

### Modifié — Application des helpers dans tout le module

| Surface | Helper appliqué | Impact |
|---|---|---|
| **Page Toolkit** (Apparence > Toolkit) — register submenu + render | `nrs_user_is_module_admin()` | Le sous-menu n'apparaît même plus pour les non-admins (early-bail dans `admin_menu` hook) |
| **AJAX config** (sync GBP, IA suggest types/knowsAbout/makesOffer, INPI, resync agences, resync global, INPI global) — 12 endpoints | `nrs_user_is_module_admin()` | Les Editors ne peuvent plus déclencher ces actions de configuration |
| **Notices QA admin** (`qa-lint.php`) | `nrs_user_is_module_admin()` | Les warnings « SIRET manquant », « Profil non configuré » ne s'affichent plus pour les Editors |
| **Endpoint REST `/health`** (Toolkit + module) | `nrs_user_is_module_admin()` (ou secret token) | Cohérence avec le reste de l'admin |
| **Metabox preview JSON-LD** (post edit) | `nrs_user_can_edit_schema()` | Visible pour Editors + Admins, cachée pour Authors / Subscribers |
| **Boutons IA « Auto-FAQ » / « Generate Description »** (ACF render fields) | `nrs_user_can_edit_schema()` | Visible pour Editors + Admins |
| **JS data + logic IA** (admin_head + admin_footer) | `nrs_user_can_edit_schema()` | JS non injecté pour les rôles non concernés (perf + clean) |
| **AJAX `nrs_generate_faq` + `nrs_generate_desc` + `nrs_clear_faq_snapshot`** | `nrs_user_can_edit_schema()` | Editors peuvent générer ; Authors bloqués |
| **Colonne « Schema »** + row action « 🧪 Tester Schema » (liste pages) | `nrs_user_can_edit_schema()` | Les Authors ne voient ni la colonne ni le bouton de test Rich Results |
| **Rendu front JSON-LD** (`wp_head`) | Inchangé (public) | C'est le but du module : émettre le JSON-LD pour Google |

### Pourquoi ce découpage 2 niveaux

**Philosophie** :
- **Admin** = configure le module (clés API, registre d'agences, identité juridique, zones globales, stratégie SEO, sync GBP). C'est de la gouvernance.
- **Editor** = applique le module sur ses pages (choisit le @type, génère FAQ via IA, vérifie le JSON-LD). C'est de la rédaction assistée.

Ce découpage est cohérent avec le pattern WordPress natif (Yoast, RankMath, SEOPress fonctionnent ainsi) et avec le rôle Editor dans WordPress qui est défini comme « gère les contenus, mais pas le site ».

### Note pour les admins
Si un client a configuré un rôle custom (ex: « Rédacteur Senior ») avec la capability `manage_options` distribuée par un plugin externe, ce rôle n'aura PAS accès à la page Toolkit (le check rôle `administrator` strict bloque). Pour le débloquer, l'admin doit :
- Soit donner explicitement le rôle `administrator` au compte
- Soit hooker le filter custom (à venir si besoin) pour autoriser un rôle custom

---

## [4.0.0] — 2026-04-22 — Support Côte d'Ivoire + identifiant RCCM OHADA

### Ajouté — 🇨🇮 Côte d'Ivoire dans la liste des pays supportés
- Nouveau choix `CI => 🇨🇮 Côte d'Ivoire` dans `nrs_get_country_choices()`.
- Injecté dans `addressCountry` (Schema.org) sur les nœuds Organization et LocalBusiness.
- La langue `fr` (français) déjà présente dans `nrs_get_language_choices()` convient pour les sites ivoiriens (aucun ajout nécessaire).

### Ajouté — Identifiant `RCCM` (Registre du Commerce et du Crédit Mobilier OHADA)
- Nouveau type d'identifiant juridique dans `nrs_get_identifier_types()` : `RCCM => 🇨🇮 RCCM (OHADA — Afrique francophone)`.
- Couvre les **17 pays OHADA** (Organisation pour l'Harmonisation en Afrique du Droit des Affaires) — Côte d'Ivoire, Sénégal, Cameroun, Burkina Faso, Togo, Bénin, Mali, Niger, Gabon, Congo, Centrafrique, Tchad, Guinée, Guinée-Bissau, Guinée équatoriale, Comores, RDC.
- Format attendu : `CI-ABJ-2023-B-12345` (pays-ville-année-série-numéro) — alphanumérique.
- **Non ajouté à `$numeric_types`** → le format alphanumérique est préservé tel quel au save (pas de retrait des tirets/lettres).
- Injecté en JSON-LD comme `PropertyValue` avec `propertyID: "RCCM"` sur le nœud Organization ou LocalBusiness (pattern identique SIREN/SIRET).

### Note pour les futurs clients OHADA
Si tu as d'autres clients en Afrique francophone (Sénégal, Cameroun, Gabon…), il te suffira d'ajouter le pays correspondant dans `nrs_get_country_choices()` (1 ligne). Le RCCM est déjà disponible comme identifiant. Autre option : filter `nrs_country_choices` dans le thème enfant pour ajouts ponctuels sans modifier le module.

---

## [4.0.0] — 2026-04-21 — IA makesOffer : prompt renforcé anti-doublons sémantiques

### Amélioré — Détection des doublons sémantiques via prompt
- Le prompt système définit désormais explicitement ce qu'est un « doublon sémantique » avec **4 exemples concrets** de paires équivalentes (différent libellé, même activité facturable) :
  - `Installation PAC` ≈ `Pose pompe à chaleur` ≈ `Montage PAC`
  - `Dépannage clim` ≈ `Intervention urgence climatisation` ≈ `SAV climatisation`
  - `Installation cuisine professionnelle` ≈ `Équipement CHR` ≈ `Agencement cuisine pro`
  - `Maintenance froid commercial` ≈ `Contrat entretien frigorifique` ≈ `Suivi technique froid`
- **Méthode de vérification en 4 étapes** imposée à l'IA avant finalisation de sa réponse :
  1. Reformuler mentalement chaque prestation candidate en 2-3 synonymes métier
  2. Comparer aux prestations « DÉJÀ présentes » + aux autres prestations de la liste
  3. Si correspondance sémantique → omettre et proposer autre chose
  4. En cas de doute → omettre plutôt que risquer la redondance
- **Exceptions autorisées** explicitement définies : variations qui apportent un niveau de précision supplémentaire significatif (techno différente, segment client différent). Ex: `Installation PAC air/eau résidentielle` vs `Installation PAC air/air tertiaire` = 2 prestations distinctes.

### Amélioré — Affichage du prompt des prestations existantes
- Avant : liste inline `Prestation A | Prestation B | …` sur une ligne.
- Après : liste à puces + consigne renforcée « ⚠️ interdiction stricte d'y ajouter un doublon EXACT ou SÉMANTIQUE » qui pointe la méthode de vérification du prompt système.
- Rappel final : « Tes suggestions doivent UNIQUEMENT élargir ce catalogue vers des nouveaux axes non-couverts, pas répéter ou reformuler ce qui existe déjà. »

### Impact attendu
Le dédoublonnage sémantique (ex: ne pas proposer « Pose pompe à chaleur » quand « Installation PAC » existe déjà) est maintenant **géré au niveau du prompt** plutôt que par un matching exact côté serveur. Gain : zéro coût supplémentaire (même nombre de tokens, même modèle), et le filet de sécurité serveur (dédup textuel) reste en place comme garde-fou.

---

## [4.0.0] — 2026-04-21 — Bugfix : reviews_global lit désormais Trustindex legacy

### Corrigé — Désynchro entre `reviews_global` et la sync Trustindex legacy
- **Symptôme** : le bloc « ⭐ Configurer les avis globaux » (livré précédemment) affichait ses propres champs rating/count séparés, alors que la card sidebar « ⭐ Avis Clients (Trustindex) » montrait des valeurs synchronisées (ex: 5/5, 5 avis). Les deux systèmes coexistaient sans se parler.
- **Cause** : le sync Trustindex stocke dans `get_option('nrs_global_rating' / 'nrs_global_review_count' / 'nrs_last_review_sync')` (options WP plates, pattern hérité v3). Le nouveau bloc `reviews_global` utilisait ses propres champs DB, ignorant la source Trustindex.
- **Fix** :
  - **UI admin** : quand `reviews_global.source === 'trustindex'`, les champs Note et Nombre d'avis deviennent **read-only** (affichage `🔒 Lu depuis la synchro Trustindex`), affichent les valeurs synchronisées, et indiquent la date de dernière sync Trustindex.
  - Bannière informative : « Synchronisé via Trustindex — pour forcer une re-synchronisation, utilise le bouton « 🔄 Synchroniser maintenant » de la sidebar. »
  - Champ Place ID masqué quand `source !== 'google-gmb'` (non applicable pour Trustindex / manuel). Valeur conservée en `<input type="hidden">` pour ne pas la perdre au save.
- **Frontend (helpers-inject.php)** : cascade de lecture pour l'émission `aggregateRating` en mode service_area :
  - `source === 'trustindex'` → lecture `nrs_global_rating` / `nrs_global_review_count` (options WP plates)
  - `source === 'google-gmb'` → lecture `reviews_global.rating` / `count` (sync par Place ID)
  - `source === 'manuel'` → bloqué (défense en profondeur conservée)
- **Effet attendu** : sur un site configuré avec Trustindex, le bloc « Avis globaux » affiche désormais automatiquement la note et le count synchronisés par Trustindex, et le JSON-LD émet ces mêmes valeurs sur le nœud Organization.

---

## [4.0.0] — 2026-04-21 — IA makesOffer enrichie (pages WP) + tableau resserré

### Amélioré — IA makesOffer : contexte des pages WordPress
- **Symptôme observé** : l'IA proposait initialement des prestations sur un seul corps de métier (ex: climatisation uniquement) alors que le site couvrait plusieurs axes (froid, chauffage, cuisine pro, ventilation). Il fallait cliquer 5-6 fois pour obtenir un catalogue couvrant tous les métiers.
- **Cause** : l'IA ne voyait que `knowsAbout` + agences + raison sociale → signaux partiels. Les **titres des pages WordPress** (le reflet éditorial le plus fiable de ce que l'entreprise propose vraiment) n'étaient pas transmis.
- **Fix** :
  - Récupération des **50 premiers titres de pages publiées** (page + post, hors HP, tri `menu_order` + titre) et injection dans le prompt utilisateur.
  - Mise à jour du prompt système : nouvelle règle qui demande explicitement à l'IA de **couvrir TOUS les corps de métier distincts** détectables dans la liste des pages (pas juste les plus évidents).
  - Coût contexte : ~50 titres × ~8 mots ≈ 400 mots (négligeable dans la fenêtre `gpt-4o-mini`).
- **Impact attendu** : catalogue multi-métiers couvert en 1 à 2 clics (vs 5-6 avant). L'IA propose spontanément des prestations ancrées dans la réalité du site.

### Modifié — Layout du tableau Catalogue de services (makesOffer)
- Le tableau dépassait la largeur de la card `🌍 Zones & Catalogue global`.
- **Refonte UI** :
  - `<table>` passe en `width:100%` + `table-layout:fixed` (au lieu de `max-width:820px`).
  - Colonne **Type** : 22% → **14%** (champ naturellement court : "CVC", "Plomberie", "SEO"…).
  - Bouton **« Supprimer »** texte → **icône 🗑️** seule (largeur 110px → 40px, gain ~70px).
  - `aria-label="Supprimer cette offre"` conservé pour l'accessibilité.
  - Inputs passent en `width:100%` au lieu de `class="regular-text"` pour respecter le table-layout.
- Modification répliquée aux **3 endroits** qui produisent des rows :
  1. Rendu PHP initial (foreach sur `$offers_list`)
  2. Template JS du bouton « ➕ Ajouter une offre »
  3. Template JS du handler IA `nrsAppendOfferRow()` (pick individuel + pick all)

---

## [4.0.0] — 2026-04-21 — Reviews globales (profil service_area)

### Ajouté — Bloc « ⭐ Avis clients (Organization globale) » en mode Zone d'intervention
- **Cas d'usage** : une entreprise en profil `service_area` (artisan itinérant, SAB) a une fiche Google Business Profile **sans adresse publique** mais qui accumule des avis. Avant cette livraison, le bloc avis n'était accessible que via les cards agence → inaccessible en profil service_area.
- **UI** : nouveau `<details>` « ⭐ Configurer les avis globaux » affiché UNIQUEMENT quand `profile === 'service_area'`, positionné juste sous la notice « aucune agence à déclarer ». Mêmes champs que le bloc agence :
  - Source (Trustindex / Google Business Profile / Manuel)
  - Place ID Google (pour sync auto)
  - Note (0-5)
  - Nombre d'avis
  - Bouton « 🔄 Resync maintenant » (si Place ID présent)
  - Freshness indicator coloré (vert < 14j, orange < 30j, rouge > 30j)
  - Preview des avis individuels récupérés
- Réutilise les classes CSS existantes (`.nrs-reviews-manuel-warning`) → bannière dynamique `source=manuel` automatique.

### Ajouté — Option DB `reviews_global`
- Nouvelle clé dans `options.reviews_global` avec structure identique à `agencies[i].reviews` :
  ```
  reviews_global: {
    source: trustindex | google-gmb | manuel
    rating, count, placeId
    last_sync: timestamp unix
    items: [5 avis Google Places max]
  }
  ```
- Sanitize ajouté dans le save handler (branche dédiée, préserve `last_sync` + `items` depuis DB).

### Ajouté — Helper `nrs_sync_global_reviews()`
- Équivalent de `nrs_sync_agency_reviews()` mais pour le bloc global.
- Lit le Place ID depuis `reviews_global.placeId`, fetch Google Places, update rating/count/items + `last_sync = time()`.
- Auto-passage de la source à `google-gmb` si encore vide.
- Invalidation cache : `nexxis_rs_update_options()` purge tous les transients graph (les reviews globales impactent TOUTES les pages car émises sur le nœud Organization ubiquitaire).

### Ajouté — AJAX handler `wp_ajax_nrs_global_reviews_resync`
- Endpoint dédié (pas de paramètre `agency_id`), appelle `nrs_sync_global_reviews()`, retourne `{rating, count, last_sync, items_count}`.
- Permissions `manage_options` + nonce `nexxis_rs_ajax` (pattern standard du module).

### Modifié — Cron hebdomadaire `nrs_cron_sync_reviews_weekly`
- Ajout d'une passe post-agences : si `reviews_global.placeId` renseigné ET `source === 'google-gmb'` → appel `nrs_sync_global_reviews()`.
- Log INFO aggregé (agences + global) via `nrs_ia_log` (compteurs `updated` / `failed`).

### Modifié — Frontend (`helpers-inject.php`)
- En profil `service_area`, émission de `aggregateRating` sur le nœud `Organization` à partir de `reviews_global` (au lieu d'un LocalBusiness qui n'existe pas dans ce profil).
- Si source = `google-gmb` et items présents, émission aussi de `review[]` nested sur l'Organization (chaque avis Google → `schema.org/Review` avec author Person + rating + body + date).
- Défense en profondeur conservée : source `manuel` → **aucune émission** du rating (Google ignore/pénalise les self-serving ratings sans source tierce vérifiable).
- Code placé AVANT le filter `nexxis_rs_organization_node` : les extensions tierces peuvent donc aussi modifier le bloc reviews après émission si besoin.

### Impact SEO
Les entreprises en service-area (artisans itinérants, SAB) peuvent désormais remonter leurs avis Google dans le graphe JSON-LD → signal de trust + éligibilité aux rich results étoilés sur les SERPs. Auparavant elles étaient pénalisées par l'architecture agence-centric du module.

---

## [4.0.0] — 2026-04-21 — UX : renommage Stratégie SEO locale + info bulle + 🤖 IA sur makesOffer

### Modifié — Label « Stratégie locale » → « Stratégie SEO locale »
- Clarification terminologique : le label était ambigu (on pouvait confondre avec une stratégie commerciale). « Stratégie SEO locale » pointe explicitement vers l'intention (pattern de maillage territorial pour le référencement).

### Ajouté — Info-bulle « ⓘ » explicative sur la Stratégie SEO locale
- Nouveau bouton `.nrs-info-btn` (même pattern que les autres infos bulles du module) à côté du select Stratégie.
- Panneau déroulant explique les 4 choix disponibles :
  - 🏰 **Forteresse** — agence fixe, rayon court, focus défense territoire
  - 🕸️ **Réseau** — multi-agences régionales, city pages par agence
  - 🚀 **Conquête (SAB)** — service-area business, zone large sans adresse publique
  - 🎯 **Hyperlocalisation** — focus quartiers/arrondissements, city pages denses
- Avec aide de décision (« Forteresse pour Solo, Réseau pour Multi, Conquête pour Service-area »).

### Ajouté — Bouton 🤖 « Suggérer via IA » sur le Catalogue de services (makesOffer)
- Nouveau bouton à côté du « ➕ Ajouter une offre ».
- Propose 4 à 8 prestations **commercialisables** distinctes à partir du contexte :
  - Raison sociale + slogan (Identité juridique)
  - Nom/tagline WordPress
  - `knowsAbout` (domaines d'expertise — sans les dupliquer dans makesOffer)
  - Agences existantes (nom + description + @type)
  - Zones couvertes (areaServed_global)
  - Offres déjà saisies (envoyées à l'IA pour éviter les doublons)
- UI : cards empilées avec nom (titre) + description (2e ligne) + serviceType (badge bleu)
- Bouton **+ Ajouter** par card OU « ➕ Tout ajouter » en bloc
- Insertion directe dans le repeater tableau `#nrs-offers-table` — même logique que le bouton « Ajouter une offre » manuel (index auto, pas de doublons)
- Nouveau handler AJAX `wp_ajax_nrs_makesoffer_suggest` (permissions `manage_options` + nonce `nexxis_rs_ajax`)
- Prompt système soigné : rappelle la distinction stricte `makesOffer` (prestations facturables) vs `knowsAbout` (thèmes d'expertise) — l'IA ne duplique pas les deux axes.

### Impact UX
Ce tandem « knowsAbout IA » + « makesOffer IA » permet à un intégrateur de remplir **tout l'étage sémantique** de l'Organization en 2 clics (+ corrections mineures). Sur un site client typique : 30 secondes pour avoir 6-8 domaines d'expertise pertinents et 5-7 prestations commercialisables distinctes, prêts pour le JSON-LD.

---

## [4.0.0] — 2026-04-18 — Fix warning R3 AboutPage (faux positif sur description ACF)

### Corrigé — Warning « Description courte ou absente » faux-positif
- Le warning R3 de `nrs_get_page_schema_warnings()` vérifiait uniquement l'excerpt WP natif pour détecter une AboutPage sans description. Depuis la v4, le champ ACF `nx_service_description` est disponible sur tous les types (y compris AboutPage) et alimente le `description` JSON-LD en priorité (cf. `$excerpt` au début de `nexxis_rs_build_page_graph`).
- Conséquence du bug : une AboutPage avec une description ACF bien remplie affichait quand même le warning « Description courte ou absente » dans le metabox si l'excerpt WP natif était vide → faux positif trompeur.
- **Fix** : le warning vérifie maintenant la même source que l'injection JSON-LD, dans le même ordre (priorité `nx_service_description`, fallback excerpt WP). Le texte du warning mentionne aussi explicitement le champ ACF pour guider l'utilisateur.

---

## [4.0.0] — 2026-04-18 — Fix `areaServed` city page : narrow scope (SEO local focus)

### Corrigé — Sur-charge de `areaServed` sur les city pages
- **Symptôme** : une page Service avec `nx_city_context = true` (ex: `/zone-aix-en-provence/` ciblée sur Aix) émettait un `areaServed` pollué avec :
  - Les cities globales du site (Pertuis, Nîmes, Avignon, Marseille…) → hors-sujet
  - Le GeoShape de l'agence liée (100 km autour de Pertuis) → mauvaise zone
  - Le GeoShape custom city page (50 km autour d'Aix) → correct
  - La ville cible en doublon (une fois `{@type:City, name:...}`, une fois `{@id:#city-...}`) → duplication
  → 8 entrées là où 2 suffisent, signal SEO local dilué.

### Fix — Scope laser sur la ville cible
- Nouvelle détection `$is_city_page = (bool) get_field('nx_city_context', $post_id)` **au début** de `nexxis_rs_build_area_served_array()`.
- Sur city page, on **skip** :
  - La boucle des cities globales
  - Le loop des GeoShape par agence
  - Le fallback GeoShape legacy
- Seul émis : le **GeoShape custom** centré sur la ville cible (calculé depuis coords ACF ou lookup ville).
- La référence `{@id: #city-xxx}` continue d'être ajoutée après (dans `helpers-inject.php`).
- **Résultat** : `areaServed` d'une city page = **2 entrées** (`GeoShape` + `City @id`), propres, cohérentes avec l'intent SEO.

### Impact
- Sur `/zone-aix-en-provence/`, Google reçoit maintenant « ce service est offert à Aix-en-Provence, rayon 50 km » — signal clair et exploitable.
- Avant : Google voyait « ce service est offert à Aix… mais aussi Pertuis, Nîmes, Marseille, Avignon… et dans un cercle centré sur Pertuis à 100 km ». Dilution totale.

---

## [4.0.0] — 2026-04-18 — CollectionPage : champ ACF explicite « Pages liées »

### Ajouté — Champ ACF `nx_haspart_pages` (relationship multi-select)
- Nouveau champ « **Pages liées (hasPart)** » dans la metabox Nexxis Schema, visible uniquement quand `nx_schema_type = collectionpage`.
- Permet de sélectionner explicitement les pages distribuées par ce hub (relationship ACF multi-select sur `page` + `post`, filtres recherche + type).
- Usage typique : hub géographique à la racine → city pages à la racine (pas en hiérarchie WP enfant) → sélection manuelle pour que `hasPart` soit émis.
- **Résolution en 2 étapes** (premier non-vide gagne) :
  1. Champ `nx_haspart_pages` rempli → priorité à l'utilisateur
  2. Fallback : enfants WordPress directs (hiérarchie `post_parent`) → automatique si le site utilise déjà la hiérarchie WP pour structurer ses hubs
- **Garde-fous** : chaque page sélectionnée est validée (existence, statut `publish`, permalink résolu) avant d'être ajoutée à `hasPart`.

### Pourquoi ce champ
Le cas d'usage observé (site *pertuisfroid.pro*) : la page hub `/ancrage-local-et-proximite/` et les city pages `/avignon/`, `/aix-en-provence/`, etc. sont toutes à la racine du site, sans hiérarchie WP parent/enfant. L'auto-détection par `post_parent` retournait un tableau vide → `hasPart` absent du JSON-LD. Le champ explicite résout ce cas sans forcer une restructuration d'URLs (qui casserait le SEO existant).

---

## [4.0.0] — 2026-04-18 — CollectionPage : pages hub / pilier avec `hasPart` auto

### Ajouté — Type `CollectionPage` pour les pages hub / pilier
- Nouveau choix dans le select **Type de page** : `📚 CollectionPage (page hub / pilier)`.
- Usage : pages qui **agrègent et distribuent** vers d'autres pages (hub géographique → city pages, pillar page topic cluster → sous-pages thématiques, catégorie métier → services). Pattern SEO très fréquent sur les sites multi-agences et multi-services.
- Schema.org : [`CollectionPage`](https://schema.org/CollectionPage) est le type canonique pour ce genre de page — signal explicite à Google « cette page est un nœud de navigation, pas un contenu terminal ».

### Ajouté — Auto-émission de `hasPart` vers les pages enfants WordPress
- Quand `nx_schema_type = collectionpage`, le nœud CollectionPage émet automatiquement une propriété `hasPart` qui liste **chaque page enfant directe** (via la hiérarchie WordPress `post_parent`).
- Chaque item est une référence `{@type: WebPage, @id, url, name}` vers l'enfant → Google comprend la **relation hub → feuilles** (hub → city pages, hub → sous-clusters…) sémantiquement, en complément du maillage HTML.
- Filtre `nexxis_rs_collectionpage_haspart` exposé : permet d'enrichir / filtrer / ajouter des pages non-enfantes via `add_filter()` si un hub doit pointer vers des pages hors hiérarchie WP.
- Tri : `menu_order` puis `title` ASC (respecte l'ordre éditorial si l'utilisateur l'a configuré dans l'Éditeur WP, sinon alphabétique).

### Impact SEO concret
Sur un site multi-agences comme *pertuisfroid.pro* (page `/ancrage-local-et-proximite/` distribuant vers /avignon/, /aix-en-provence/, /arles/, /ales/…) :
- Google reçoit un signal structuré que cette page est un hub géographique
- La relation page hub ↔ city pages est explicitée dans le graphe
- Le pillar page pattern (pilier + topic cluster) s'en trouve renforcé pour Google

---

## [4.0.0] — 2026-04-18 — Fix : `nx_hide_reviews` stale bloquait aggregateRating (v4 multi-agences)

### Corrigé — Toggle « Masquer les avis » stale sur pages avec agence liée
- **Symptôme** : après la mise en place du masquage conditionnel du tab « Avis / Reviews » (livré juste avant), certaines pages LocalBusiness avec agence liée retournaient `nx_hide_reviews = true` en DB (valeur stale d'une édition antérieure). Comme le tab est maintenant caché sur ces pages, l'utilisateur ne pouvait plus remettre le toggle à OFF → le JSON-LD n'émettait jamais le `aggregateRating` même avec une agence correctement synchronisée.
- **Fix** : dans `helpers-inject.php`, la valeur `nx_hide_reviews` est maintenant **ignorée** si :
  - une agence est liée à la page (`$linked_agency` non null et `id` renseigné)
  - ET le type de page n'est pas `product` (le tab reste visible sur Product)
- Logique : si le tab est caché, la valeur DB ne peut plus être modifiée → on l'ignore à l'injection. Le toggle reste fonctionnel sur `Product` et sur les pages `LocalBusiness`/`Service` **sans agence liée** (où le tab reste visible).
- Log INFO via `nrs_ia_log` si `NXT_DEBUG` est actif, pour suivre les pages où une valeur stale est ignorée.

### Ajouté — Panneau diagnostic « 🔍 Debug reviews/agence » dans la metabox preview
- Nouveau bloc dans [metabox-helper.php](modules/rich-snippets/metabox-helper.php) (affiché uniquement pour les pages `service` / `localbusiness`).
- Affiche en un coup d'œil : `nx_schema_type`, `nx_agency_id`, `nx_hide_reviews` (DB vs effectif), données de l'agence résolue (`reviews.source / rating / count / items / last_sync`), et **décision finale** du moteur d'injection (`✓ aggregateRating SERA émis` ou `✗ NE sera PAS émis` avec la raison).
- Permet à l'utilisateur de comprendre rapidement pourquoi une page n'émet pas (ou émet) les avis attendus, sans avoir à lire le JSON généré.
- **Gate** : visible uniquement si `define('NXT_DEBUG', true)` est actif dans `wp-config.php` → propre en prod, réactivable en 2 sec pour debug.

---

## [4.0.0] — 2026-04-18 — Fix critique cache transient JSON-LD (object cache compat)

### Corrigé — Cache transient stale après sync agency (Redis/Memcache-safe)
- **Symptôme** : après une sync d'avis Google Business Profile sur une agence (bouton « 🔄 Resynchroniser »), le JSON-LD des pages liées à cette agence **ne reflétait pas** les nouveaux avis (`aggregateRating` absent, `review[]` absent) — même après F5 de la page éditeur.
- **Cause** : `nexxis_rs_update_options()` invalidait les transients via un bulk SQL `DELETE FROM wp_options WHERE option_name LIKE '_transient_nrs_graph_%'`. Cette requête ne touche **pas** l'object cache (Redis / Memcache / Object Cache Pro) : si un tel cache est actif, les transients y résident et sont servis en priorité par `get_transient()`. Résultat : le graphe cache restait servi 24 h (TTL transient) alors que les données sous-jacentes avaient changé.
- **Fix 1** — `nexxis_rs_update_options()` : remplacement du bulk SQL par une itération `SELECT option_name → delete_transient()`. `delete_transient()` nettoie correctement les **deux** couches (wp_options + object cache).
- **Fix 2** — `nrs_sync_agency_reviews()` : ajout d'une **invalidation ciblée** supplémentaire après le save. Le handler itère tous les posts ayant `nx_agency_id = $agency_id` et appelle `delete_transient('nrs_graph_' . $pid)` pour chacun. Filet de sécurité au cas où un object cache persistant stockerait des transients hors de wp_options (cas extrême).
- **Fix 3** — UX : la réponse AJAX inclut maintenant `cache_cleared_posts`. Le feedback du bouton Resynchroniser affiche : `✓ Sync OK · 💬 5 avis individuel(s) récupéré(s) · 🗑️ cache vidé sur N page(s) liée(s)` → confirmation visuelle que le cache a bien été purgé.

### Note
Après déploiement, **resynchronise l'agence Nîmes** puis **F5 la page éditeur Nîmes**. Le JSON-LD doit maintenant inclure `aggregateRating` et `review[]` correctement.

---

## [4.0.0] — 2026-04-18 — Tab Avis/Reviews caché quand agence liée (cleanup UX)

### Modifié — Tab « Avis / Reviews » conditionnel en v4 multi-agences
- **Avant** : le tab apparaissait systématiquement sur les pages `LocalBusiness` / `Service` / `Product`, avec 3 champs d'override (Masquer, Note Locale Surcharge, Nombre d'avis Local). En v4 multi-agences, la source de vérité des avis est l'**agence liée** — ces champs devenaient du bruit visuel susceptible d'erreurs de saisie.
- **Maintenant** : le tab (et ses 3 champs internes) ne s'affichent que dans 3 cas :
  - `Product` → toujours (per-page reviews pertinentes)
  - `LocalBusiness` **sans** agence liée → legacy Solo / cas edge → override utile
  - `Service` **sans** agence liée → idem
- Conséquence : sur une page LB / Service **avec une agence liée** (cas nominal v4), le tab disparaît → l'utilisateur ne peut plus surcharger par erreur les avis de l'agence.
- Note technique : les tabs ACF ne propagent PAS leur visibilité aux fields suivants — la `conditional_logic` a été dupliquée sur les 3 champs internes (`nx_hide_reviews`, `nx_local_rating`, `nx_local_review_count`) pour que la disparition soit cohérente.

---

## [4.0.0] — 2026-04-18 — Bugfix `areaServed` scopé à l'agence liée (multi-agences)

### Corrigé — GeoShape d'une autre agence sur une page agence dédiée
- **Symptôme constaté sur la page « Agence Nîmes »** : le JSON-LD émettait un `GeoShape circle: 43.679,5.504 100000` — coordonnées **de Pertuis** — au lieu d'un cercle centré sur Nîmes (43.836,4.421). Le cercle de 100 km autour de Pertuis ne couvrait même pas Nîmes.
- **Cause** : `nexxis_rs_build_area_served_array()` itérait sur **TOUTES** les agences du registre sans savoir à quelle agence la page courante était dédiée. Sur une page LB/Service liée à Nîmes, le GeoShape de Pertuis (la seule qui avait son `radius_km` rempli) était quand même émis.
- **Fix** : nouvelle signature `nexxis_rs_build_area_served_array($post_id, $linked_agency)`. Sur une page Service ou LocalBusiness liée à une agence spécifique, seul le GeoShape de cette agence est émis. Sur la HP / niveau Organization, toutes les agences continuent d'être agrégées (comportement Organization-level préservé).
- **Effet de bord positif** : si l'agence liée n'a pas de `radius_km` renseigné → aucun GeoShape émis (au lieu d'un fallback faux centré sur l'agence principale). Logique : « pas de zone déclarée → pas de zone émise ».
- Appelant dans `helpers-inject.php` mis à jour : `$linked_agency` est maintenant résolu AVANT `$areaServed` et passé en argument.

### Note pour les utilisateurs
Sur une page agence dédiée, pour voir un GeoShape dans le JSON-LD, il faut renseigner le champ **Rayon d'intervention (km)** dans la fiche de cette agence (section Géolocalisation). Sinon `areaServed` contiendra uniquement les villes globales.

---

## [4.0.0] — 2026-04-18 — Colonne Schema : masquage badge agence orphelin + description tous types

### Corrigé — Badge 🏢 agence visible sur pages qui ne la consomment pas
- Symptôme : une page passée de `LocalBusiness` à `WebPage` conservait son `nx_agency_id` en DB (préservé volontairement pour si l'utilisateur rebascule). La colonne Schema affichait donc `WebPage 🏢 Nom-Agence` — affichage trompeur puisque le JSON-LD ne consomme plus l'agence.
- Fix dans `admin-pages-list.php` : le badge agence ne s'affiche que pour les types `service` / `localbusiness` (ceux qui la consomment effectivement). La valeur reste en DB pour préservation du choix utilisateur.

### Modifié — Indicateur 📝 description étendu à tous les types
- Suite au passage de la description en champ permanent (tous types), l'indicateur 📝 dans la colonne Schema s'affiche désormais pour tout type qui émet une description en JSON-LD (Service, LocalBusiness, WebPage, AboutPage, Article, Product, Event, Recipe, Course, Video, FAQPage, HowTo, Review, Job). Seuls `other` et type vide sont exclus.

---

## [4.0.0] — 2026-04-18 — Description permanente + FAQ pliable + cascade rename agence

### Ajouté — Champ `Description (SEO) pour le Schema.org` permanent (tous types)
- Historiquement, le champ `nx_service_description` était caché dans l'onglet **Détails du Service**, conditionnel à `service` / `localbusiness`. Résultat : impossible de personnaliser la description pour une WebPage, AboutPage, Article, Product, etc. → le JSON-LD utilisait systématiquement l'excerpt WP natif.
- Le champ est maintenant **remonté au-dessus** (juste sous « Type de page ») et toujours visible, quel que soit le type sélectionné. Le bouton **🤖 Générer Description & Type via IA** reste attaché (hook `acf/render_field/key=field_nx_service_desc`).
- **Nom ACF inchangé** (`nx_service_description`) pour préserver les données existantes des pages déjà sauvegardées — aucune migration nécessaire.
- Dans `helpers-inject.php`, `$excerpt` est maintenant initialisé **dès le début** avec la logique `nx_service_description` → fallback excerpt WP, donc tous les nœuds (WebPage, AboutPage, Article, Product, Event, Recipe, Course, Video, Service, LocalBusiness…) bénéficient automatiquement du champ. Plus besoin de dupliquer la logique par type.
- Seul `nx_service_type` (label « Type de Service ») reste conditionnel à service/localbusiness — c'est un concept spécifique au schéma Service.

### Ajouté — FAQ générée dans un accordion pliable
- Après génération IA (6-10 questions typiquement), la liste FAQ occupait beaucoup d'espace vertical dans l'éditeur.
- Le repeater FAQ est maintenant wrappé dans un **accordion ACF replié par défaut** : un clic sur le titre déplie/replie la liste. Label : « FAQ — liste des questions (cliquer pour déplier/replier) ».
- Le bouton « Auto-générer via IA » et le bouton « Vider » restent au-dessus de l'accordion (attachés via `acf/render_field` sur le champ repeater, comportement inchangé).

### Ajouté — Cascade migration des pages quand un slug d'agence est renommé
- **Problème** : renommer le slug d'une agence (ex : `principal` → `pertuis-froid-pertuis` pour cohérence) orphelinisait silencieusement toutes les pages déjà liées à l'ancien slug via `nx_agency_id`. L'utilisateur devait ré-ouvrir chaque page manuellement pour corriger la liaison.
- **Fix** : nouveau hidden input `_prev_id` par agence qui porte le slug DB courant à l'affichage du form. Au save, si `_prev_id` ≠ nouveau slug → mapping `old_slug → new_slug` accumulé. En fin de save, un `update_post_meta` est exécuté pour tous les posts (tous CPT) ayant `nx_agency_id = old_slug`, automatiquement migrés vers le nouveau slug.
- Feedback admin : `🔄 N page(s) migrée(s) vers le nouveau slug d'agence.`
- Permet maintenant de renommer un slug en toute sécurité (zéro risque d'orpheliner les pages).

### Modifié — `nexxis_rs_get_agencies_for_acf()` n'injecte plus d'option vide
- Cohérent avec le fix « unlink agence » précédent : l'option « — Aucune liaison — » est maintenant gérée exclusivement par le combo `allow_null` + `ui` + `placeholder` d'ACF, plus par un hack dans `choices`.

---

## [4.0.0] — 2026-04-18 — Unlink agence + warnings pédagogiques

### Corrigé — Impossible de revenir à « Aucune agence » après sélection
- Le champ ACF `nx_agency_id` utilisait un hack : option `'' => '— Aucune liaison —'` injectée manuellement dans `choices` + `allow_null => 1`. La combinaison créait deux options vides conflictuelles → une fois une agence sélectionnée, aucun moyen propre de revenir à vide.
- **Fix** : retrait de l'option manuelle, activation de `ui => 1` (Select2) + `placeholder` + `allow_null => 1` → la croix (×) Select2 s'affiche à droite du champ pour effacer la sélection. Comportement standard ACF, zéro hack.

### Ajouté — Warnings pédagogiques « double sortie »
Les règles `R1` et `R2` de `nrs_get_page_schema_warnings()` proposent désormais **deux solutions** au lieu d'une seule prescription.

- **R2 (Local Business + mode Multi + pas d'agence liée)** — renommée « Type Local Business sans agence liée » :
  - Option 1 : lier une agence (si la page = établissement physique précis)
  - Option 2 : changer `nx_schema_type` en `WebPage` / `AboutPage` / `Service` (si la page parle de l'entreprise en général — cas fréquent pour accueil/services globaux en multi-agences)
  - Rappel : « un seul Local Business par établissement physique, sur sa page canonique uniquement »
- **R1 (city page + Local Business + pas d'agence)** — même traitement : soit changer le type en `Service` (recommandé pour city page), soit lier une agence si la page représente vraiment un établissement.

Ces warnings guident l'utilisateur vers le bon pattern Schema.org en multi-agences : **HP / services génériques → WebPage/Service** (avec `provider → #organization`), **pages agence dédiées → LocalBusiness + agence liée**.

---

## [4.0.0] — 2026-04-18 — Bugfix SIRET : discard silencieux + notice non dismissible

### Corrigé — SIRET effacé silencieusement si ≠ 14 chiffres
- **Avant** : `nrs_sanitize_agency()` vidait le champ SIRET de l'agence si la longueur n'était pas exactement 14 chiffres. L'utilisateur saisissait un SIRET (13 chiffres par typo, SIREN à 9 chiffres par confusion, etc.), cliquait « Enregistrer », et la valeur disparaissait sans aucun feedback. La notice « Agence française sans SIRET » continuait à apparaître sans explication.
- **Maintenant** : on préserve les chiffres saisis (strip non-digits, mais conserve la valeur). L'utilisateur peut ré-ouvrir l'agence et voir ce qu'il avait effectivement tapé pour corriger.
- **Validation décalée au rendu** : l'injection JSON-LD (`helpers-inject.php`) vérifie maintenant `strlen === 14 && ctype_digit` avant d'émettre le nœud `identifier` PropertyValue SIRET. Un SIRET malformé n'est donc jamais injecté, ce qui protège le graphe Schema.org.

### Ajouté — Distinction SIRET manquant vs malformé
- **Notice admin (qa-lint)** : deux messages distincts désormais.
  - *Manquant* : « Utilise 🇫🇷 Depuis le Registre FR pour auto-compléter »
  - *Invalide* : « N chiffres au lieu de 14 — ré-ouvre la fiche agence et corrige »
- **Dashboard card « SIRET agences FR »** : affiche `N manquant(s) · M invalide(s)` avec un tip adapté si des invalides sont détectés.

### Ajouté — Notice admin `is-dismissible`
- La notice « Nexxis Rich Snippets — Vérifications » a maintenant la croix de fermeture standard WordPress (`is-dismissible`). Utile pour la masquer temporairement en attendant la correction, sans avoir à scroller par-dessus en permanence.

---

## [4.0.0] — 2026-04-18 — Bouton IA sur knowsAbout + tip pédagogique LocalBusiness

### Ajouté — 🤖 Suggestion IA de domaines d'expertise (knowsAbout)
- Nouveau bouton « 🤖 Suggérer via IA » sous la textarea `knowsAbout` (section Identité juridique).
- Propose 4 à 8 thèmes d'expertise à partir du contexte disponible : raison sociale, slogan, nom/tagline WordPress, première agence (nom, description, @type), pays principal.
- Badges cliquables : « + Terme » pour ajout individuel, « ➕ Tout ajouter » pour bulk.
- Dédoublonnage côté serveur ET client : les termes déjà saisis dans la textarea sont envoyés à l'IA pour l'empêcher de les repropoposer, et toute tentative d'ajout en double est bloquée.
- Garde-fou anti-drift IA : termes > 80 caractères rejetés, max 8 termes gardés.
- Nouveau handler AJAX `wp_ajax_nrs_knowsabout_suggest` (permissions `manage_options` + nonce `nexxis_rs_ajax`).

### Ajouté — 💡 Tip pédagogique : quand utiliser `LocalBusiness` seul
- Info-icon + panneau déroulant à côté du bouton « 🤖 Suggérer via IA » sur chaque champ **Types Schema.org** (agence existante + template pour nouvelles agences).
- Contenu : règle « LocalBusiness seul = dernier recours, uniquement si aucun sous-type plus précis n'existe », exemples concrets (plombier, restaurant, agence web, activité générique), rappel que les sous-types **héritent** déjà de LocalBusiness.
- Objectif : éviter que l'utilisateur garde `LocalBusiness` en plus des suggestions IA précises (redondance sémantique déconseillée).

### Modifié — Description knowsAbout reformulée
- Ancienne formulation « 🤖 Utilisé par l'IA « Suggérer via IA » des agences » était ambiguë (suggérait à tort un bouton IA direct sur knowsAbout).
- Nouvelle formulation explicite : « Ces termes sont aussi consommés par l'IA des agences pour suggérer les @type Schema.org les plus précis ».

---

## [4.0.0] — 2026-04-18 — Bugfix : section Config API mangée par le wrap JS

### Corrigé — JS wrap : `nextUntil` consumait les sections pré-wrappées
- Le wrap automatique des h2 en `<section class="nrs-section">` utilisait `nextUntil('h2, p.submit, ...')` pour grouper chaque h2 avec ses frères suivants. Le sélecteur ne s'arrêtait **pas** sur les `<section class="nrs-section">` déjà présentes dans le HTML (ex : Config API, pré-wrappée car elle n'a pas de h2 direct enfant du form).
- Conséquence : quand le wrap traitait la section « Agences physiques », son `nextUntil` engloutissait la section Config API et la nichait **à l'intérieur** de la section Agences dans le DOM final → invisible visuellement (car le grid layout ne s'applique que sur les enfants directs du form).
- Fix : ajout de `section.nrs-section` dans le sélecteur stop de `nextUntil` → les sections pré-wrappées sont maintenant respectées comme séparateurs.
- Impact : la card « 🔧 Configuration API » réapparaît correctement en colonne 1 quand une clé API manque en wp-config.

---

## [4.0.0] — 2026-04-18 — UX : Config API masquée si tout en wp-config, IA passe en 1/1

### Ajouté — Masquage intelligent du card "Configuration API"
- Quand **OPENAI_KEY + GPLACES_KEY** sont toutes deux définies en `wp-config.php` → le card « 🔧 Configuration API » **disparaît** (aucun intérêt à afficher un guide d'installation pour une config déjà en place).
- Dans ce cas, le form reçoit `data-config-api-hidden="1"` et la card « 🤖 Intelligence Artificielle » passe en **pleine largeur** (`grid-column: 1 / -1`) au lieu de rester en 1/2 avec un blanc à côté.
- Quand **une seule clé manque** → le card s'affiche mais le snippet n'inclut que la ligne `define()` pertinente (ex : uniquement `NEXXIS_RS_GPLACES_KEY` si OpenAI est déjà définie).
- Les liens « Obtenir les clés » sont filtrés de la même façon (OpenAI / Google Cloud Console affichés uniquement si la clé correspondante manque).

### Modifié — Description IA contextualisée
- La mention « voir 🔧 Configuration API ci-contre » dans la description IA n'apparaît que si le card Config API est visible (sinon lien mort).

---

## [4.0.0] — 2026-04-18 — UX : Config API + IA côte à côte + nettoyage dead code

### Modifié — Layout 2 colonnes pour Config API + IA
- Le form principal passe en **CSS Grid 2 colonnes** (`grid-template-columns: 1fr 1fr`).
- **Config API (order 1)** + **IA (order 2)** prennent chacun 1/2 colonne (`grid-column: span 1`) → alignés sur la même rangée.
- Toutes les autres sections (Stratégie SEO, Profil, Identité, Zones, Agences) conservent la pleine largeur via `grid-column: 1 / -1`.
- Les boutons submit/reset (non-section) prennent aussi la pleine largeur et restent en fin de form via `order: 99`.
- **Responsive** : sous 900px, tout repasse en 1 colonne (`grid-template-columns: 1fr`) pour garder la lisibilité mobile/tablette.

### Modifié — Config API intégrée comme section standard
- Le `<details>`/`<summary>` collapsible au-dessus du layout est retiré.
- Config API devient une **vraie section card** pré-wrappée dans le form (avec `data-nrs-order="1"` fixé en HTML, hors de la wrap JS automatique).
- Style cohérent avec les autres sections (même padding, border, shadow, h2 style).
- Snippets `define()` toujours visibles (plus besoin de cliquer pour déplier — peu de contenu, info utile au premier coup d'œil).

### Modifié — ORDER_MAP JS mis à jour
- `nrs-section-ia` : order **2** (était 1)
- `nrs-section-strategy` : order **3** (était 2)
- Config API (pré-wrappée en HTML) : order 1
- Profil 10, Identité 20, Zones 30, Agences 40 (inchangés)

### Supprimé — `enable_snapshot` (dead code)
La clé `enable_snapshot` de `nrs_get_instance_features()` n'était référencée nulle part dans le code. Constante `NEXXIS_RS_ENABLE_SNAPSHOT` déclarable dans wp-config.php mais jamais consommée. Retrait du dead code + note en commentaire pour retirer la constante côté wp-config également.

### Note pour les admins
Dans `wp-config.php`, ces lignes peuvent maintenant être **supprimées sans impact** :
- `define('NEXXIS_RS_ENABLE_SNAPSHOT', true);` (jamais utilisée)
- `error_log('WP-BOOT OK '.date('c'));` (test obsolète qui pollue `debug.log`)

À **garder** :
- `define('NEXXIS_RS_OPENAI_KEY', '…');` + `define('NEXXIS_RS_GPLACES_KEY', '…');` (clés API)
- `define('NEXXIS_RS_USE_CACHE', true);` (perf)
- `define('NEXXIS_RS_IA_DEBUG', true);` (uniquement si dev/staging)

---

## [4.0.0] — 2026-04-18 — UX : dashboard clés API + config wp-config first

### Modifié — Dashboard cards clés API (format emoji + source courte)
La carte « Clé OpenAI » du dashboard remplace la notice verte/rouge historique. Nouveau format unifié et compact :
- **🟢 wp-config** : clé via `NEXXIS_RS_OPENAI_KEY` (recommandé)
- **🟢 env** : variable d'environnement (Docker/Forge/Ploi)
- **🟠 BDD** : clé stockée en base de données (warning — migrer vers wp-config)
- **❌ Manquante** : pas configurée → features IA désactivées

### Ajouté — Carte « Clé Google Places » dans le dashboard
Même format que la carte OpenAI. Évalue la présence de `NEXXIS_RS_GPLACES_KEY` (ou l'option legacy DB). Indique en un coup d'œil si l'auto-fill Google Places par agence + la sync hebdomadaire des reviews sont opérationnels.

### Ajouté — Panneau « 🔧 Configuration API » (collapsible)
Placé juste sous le dashboard. Déplié automatiquement si au moins une clé n'est pas en wp-config (incite à la migration). Contient :
- Recommandations sécurité (pas dans les dumps SQL, pas via compromission admin, overridables par env).
- **Snippets `define()` prêts à copier** pour les 2 clés OpenAI + Google Places.
- Liens directs vers OpenAI Platform et Google Cloud Console.

Design : header gradient orange clair (attention utile), code block sombre façon terminal pour le snippet.

### Supprimé — Input clé OpenAI dans la section IA
Retrait de :
- Champ `<input type="password">` pour saisie de la clé
- Bouton « Vérifier la clé »
- Bouton « 👁️ Afficher / Masquer »
- Span statut
- Phrase « Priorité : wp-config.php → Base de données → Variable d'environnement » (devenue inutile sans input)

**Rationale** : la meilleure pratique sécurité est d'utiliser exclusivement `wp-config.php`. Garder un input en base de données invitait à des saves en clair, à des fuites via dumps SQL, etc. La saisie par l'UI était un héritage v3 non aligné sur les standards actuels.

**Rétrocompat** : les clés déjà stockées en DB (installations antérieures) continuent de fonctionner via le fallback de `nrs_get_openai_key()`. Le dashboard affiche un warning orange « 🟠 BDD » pour inciter à la migration.

### Préservé
- **Model selector** dans la section IA (`gpt-4o-mini` / `gpt-4o` / `gpt-3.5-turbo`) — marginal mais utile pour override power user. Défaut `gpt-4o-mini` optimal pour les usages module (sub-types, FAQ). Override en code possible via `add_filter('nrs_openai_model', …)`.
- Save handler : detection `isset($_POST['openaiapikey'])` → comportement défensif existant préserve la clé DB existante si l'input n'est plus présent dans le form.

---

## [4.0.0] — 2026-04-18 — Phase 1 UX : sections wrappées + réordonnancement propre

3 corrections basées sur le feedback post-déploiement.

### Ajouté — Wrap propre des sections dans `.nrs-section`
Chaque `<h2>` + siblings (jusqu'au prochain `<h2>` ou au bouton submit/reset) est wrappé en JS au chargement dans un `<section class="nrs-section" data-nrs-order="N">`. Avantages :
- **Card complète** : la description juste après le h2 est enfin **dans le card blanc** (plus en gris flottant).
- **Padding aéré** (`28px` horizontal) : les textes ne sont plus collés aux bords.
- **Bordures cohérentes** (1px #dcdcde, border-radius 6px, shadow subtile) pour toutes les sections.
- H3 stylisés en "sous-cards" dans les sections (bordure gauche grise).
- Animation opacity 0→1 (150ms) au chargement pour éviter le flash pendant le wrapping.

### Modifié — IA + Stratégie SEO remontent en tête (cette fois proprement)
Abandon du trick `ob_start()` buggé. Utilisation d'une approche CSS `flex` + `order` + attribut `data-nrs-order` calculé en JS :
- IA → `order: 1`
- Stratégie SEO → `order: 2`
- Profil → `order: 10`
- Identité → `order: 20`
- Zones & Catalogue → `order: 30`
- Agences → `order: 40`
- Boutons submit/reset → `order: 99` (toujours en bas)

Zéro manipulation du code source PHP des sections — la réorganisation est purement côté présentation. Maintenance facile : pour changer l'ordre, il suffit de modifier le `ORDER_MAP` JS.

### Notes
- Le wrap en JS est instantané (jQuery `.wrapAll`). Le flash anti-FOUC via opacity rend la transition imperceptible.
- Extension future : n'importe quelle section ajoutée par un plugin tiers est automatiquement wrappée si elle commence par un `<h2>` enfant direct du form.

---

## [4.0.0] — 2026-04-18 — Phase 1 UX : correctifs (crash JS + cards visuelles)

Correctifs suite au feedback du premier déploiement Phase 1.

### Corrigé — Crash JS qui bloquait l'accordéon agence + Stratégie absente
Le trick `ob_start()` pour déplacer IA + Stratégie SEO en tête du form avait un **bug d'ordre d'exécution PHP** : la capture ob_start/ob_get_clean s'exécutait APRÈS le `echo $var` au point d'insertion, donc la variable était vide et les sections IA + Stratégie n'étaient pas rendues du tout. Conséquence en cascade :
- Textarea cities absente du DOM
- `$(selector).val()` renvoyait `undefined`
- `.split('\n')` crashait → `TypeError: Cannot read properties of undefined (reading 'split')`
- L'erreur brisait le scope jQuery ready → tous les handlers suivants (dont l'accordéon agence #1) n'étaient jamais attachés.

**Fix** :
- Revert complet du trick `ob_start` → IA et Stratégie SEO retournent temporairement à leur position originale (après Agences).
- **Ajout défensif** dans `nrsReadTextareaCities` : `$(sel).val() || ''` pour ne jamais crasher sur `undefined`.
- Accordéon agence #1 fonctionne de nouveau (réparé en cascade).

**Reorg IA-en-tête reporté** : nécessite un refacto propre via extraction des sections en fonctions PHP dédiées (pas d'ob_start hack). À livrer dans un patch dédié.

### Ajouté — Cards visuelles pour distinguer les sections
Retour justifié du user : les blocs étaient difficiles à identifier visuellement. Désormais chaque section a :
- **H2 styled comme card-header** : gradient bleu léger, bordure gauche 5px bleu primary, shadow subtil, border-radius 6px.
- **Form-table attachée** au h2 : bordure continue, background blanc, coins arrondis en bas uniquement (continuité visuelle du card).
- **H3 secondaires** : bordure gauche 3px gris, background gris clair pour hiérarchie claire.
- **HR en tirets** au lieu de lignes pleines pour séparer subtilement.

Chaque section « Profil », « Identité », « Zones & Catalogue », « Agences », « IA », « Stratégie SEO » devient un **bloc-carte distinct** sans alourdir visuellement.

---

## [4.0.0] — 2026-04-18 — Phase 1 UX : refonte admin (sidebar, dashboard, info icons, sticky save)

Refonte complète de la page de réglages avec une approche moderne inspirée Apple / Tailwind / Material Design, tout en respectant les conventions WP admin.

### Ajouté — Layout 2 colonnes responsive
- **Main** (70%) : configuration (form avec toutes les sections).
- **Sidebar droite** (320px sticky) : monitoring read-only — Avis Trustindex, Health Check endpoint, Debug areaServed.
- **Breakpoint 1200px** : bascule en pile verticale sur tablette/mobile.

### Ajouté — Dashboard « 📊 État du graphe Schema » en tête
Vue d'ensemble immédiate avec 6-7 cartes colorées (vert/orange/rouge selon état) :
- Profil (Solo/Multi/Service-area)
- Agences déclarées
- Identité juridique (legalName / identifier / vatID) — score X/3
- SIRET agences FR manquants (badge rouge si > 0)
- Zones couvertes (areaServed global)
- Offres catalogue (makesOffer)
- Certifications (awards)

Helper PHP exposé : `nrs_get_dashboard_summary()` retourne un array de cartes prêt à rendre.

### Ajouté — Sticky save bar (bas de page)
- Bouton **💾 Sauvegarder** toujours visible quand l'utilisateur a des modifs non enregistrées.
- **Indicateur orange** *« ⚠️ Modifications non enregistrées »* à gauche.
- **Raccourci Ctrl/Cmd+S** pour sauvegarder sans chercher le bouton. Hint affiché (`<kbd>Ctrl</kbd>+<kbd>S</kbd>`, `⌘+S` sur mac détecté via `navigator.platform`).
- **Warning `beforeunload`** si modifs non sauvegardées avant navigation.
- Sticky bar apparaît uniquement quand dirty (animation slide-up).

### Ajouté — Info icons (ⓘ) à la place des encadrés bleus fixes
- Nouveau pattern : bouton `.nrs-info-btn` avec `dashicons-info-outline` + panel pliable à côté.
- Accessible : `aria-expanded`, `aria-label`, `<span hidden>` toggle.
- Remplace les 3 gros encadrés bleus explicatifs (knowsAbout vs makesOffer, areaServed global vs cities, makesOffer vs knowsAbout).
- Réduit le bruit visuel en mode lecture, informatif au besoin.

### Modifié — Réorganisation de l'ordre des sections
Logique **Tools → Strategy → Identity → Details** :
1. 🤖 Intelligence Artificielle (pré-requis de « Suggérer via IA »)
2. 🎯 Stratégie SEO & données du contrat (renommé, voir ci-dessous)
3. 🧭 Profil & Localisation
4. 🏛️ Identité juridique
5. 🌍 Zones & Catalogue global
6. 🏢 Agences physiques

Implémentation : output buffering (`ob_start()` / `ob_get_clean()`) pour capturer IA + Stratégie et les rendre en tête du form — refacto minimal, pas de duplication de code.

### Modifié — Renommages
- **« 🏢 Données Entreprise »** → **« 🎯 Stratégie SEO & données du contrat »** (aligné sur le langage agence)
- **« Villes desservies (1 par ligne) »** → **« Villes choisies dans le contrat (1 par ligne) »**

### Modifié — Custom Post Types : textarea → checkbox list auto-détectée
- Avant : textarea où l'utilisateur tapait les slugs à la main.
- Maintenant : **grid de checkboxes** listant tous les CPT publics déclarés par WP/theme/plugins via `get_post_types(['public' => true])`.
- Pré-cochés : ceux déjà dans l'option + defaults page/post.
- Filtre `nrs_available_post_types_exclude` pour masquer des types techniques (par défaut : `attachment`, `nav_menu_item`).
- Si nouveau CPT créé via ACF/CPT UI/code → apparaît automatiquement à la prochaine ouverture.

### Supprimé
- **Champ « Rayon d'intervention global (km) »** dans la section Stratégie SEO : redondant avec le rayon par agence (Brique 4). Valeur DB préservée en fallback silencieux. Notice plus affichée.
- **Checkbox « Injection automatique dans `<head>` »** : l'injection est désormais **toujours active par défaut**. Pour désactiver en code : `add_filter('nrs_should_auto_inject', '__return_false')` dans functions.php d'un plugin tiers.

### Modifié — CSS modernisé
- Cards avec `border-radius: 6px`, `box-shadow: 0 1px 3px rgba(0,0,0,.04)` (Material subtle).
- Sidebar sticky avec fond blanc + bordure.
- Sticky save bar avec `backdrop-filter: blur(10px)` et animation translate.
- Palette cohérente (bleu `#2271b1`, vert `#00a32a`, orange `#f59e0b`, rouge `#d63638`).
- Typography hiérarchie claire, spacing généreux.

### Notes
- Aucune régression fonctionnelle : toutes les sections et fonctionnalités des briques 1-8 sont préservées.
- Réorganisation via output buffering = zéro duplication de code = maintenance facile.
- Le tooltip/info pattern est réutilisable — ajoutable sur tout champ existant en 2 lignes.

---

## [4.0.0] — 2026-04-18 — Brique 7.A : Sync auto reviews Google + défense source=manuel

Version pragmatique de la Brique 7 (la Brique 7 complète nécessitait du Trustindex payant, reportée). Se focalise sur les reviews Google Places qui couvrent 95 % des cas gratuitement.

### Ajouté — Cron hebdomadaire de sync reviews Google Places
- **Hook `nexxis_rs_cron_sync_reviews`** planifié en `weekly` dans les 2 loaders (`class-rich-snippets.php` + `nexxis-rich-snippets.php`).
- Callback `nrs_cron_sync_reviews_weekly()` : itère les agences avec `reviews.source = 'google-gmb'` + `placeId` non vide → re-fetch Google Places → met à jour `reviews.rating`, `reviews.count`, `reviews.last_sync`.
- Silencieux : ne touche pas aux autres champs de l'agence. Erreurs individuelles loguées via `nrs_ia_log` sans interrompre le batch.
- Quota Google respectueux : ~1 requête/agence/semaine (ex : 10 agences × 52 semaines = 520 req/an, négligeable).

### Ajouté — Nouveau champ `reviews.last_sync` (timestamp)
- Ajouté à `nrs_empty_agency()` et préservé au save (non éditable via form).
- Maj automatique par le cron, le resync manuel et l'import Google Places.

### Ajouté — Bouton « 🔄 Resync maintenant » par agence + indicateur freshness
- Visible dans la sous-section « ⭐ Avis clients » d'une agence **si un Place ID est renseigné**.
- Indicateur coloré **📅 il y a X jours** :
  - Vert < 14 jours
  - Orange 14-30 jours
  - Rouge > 30 jours
- Nouvel AJAX handler `nrs_agency_resync_reviews` pour forcer la sync à la demande.
- Retour immédiat des champs rating/count dans l'UI (flash vert).

### Ajouté — Défense en profondeur contre le `source = 'manuel'`
1. **Notice warning dynamique** (rouge, bien visible) affichée directement sous le summary dès que la source passe à `manuel` — sans attendre la sauvegarde.
2. **Blocage du rendu JSON-LD** dans `helpers-inject.php` : si l'agence liée a `reviews.source = 'manuel'`, le `aggregateRating` **n'est PAS émis** sur le `LocalBusiness`, même si rating + count sont renseignés.
3. Override local toujours possible via `nx_local_rating` + `nx_local_review_count` au niveau page (l'utilisateur prend explicitement la responsabilité).
4. Fallback `nrs_global_rating` désactivé aussi quand l'agence liée est en source manuel — pas de maquillage.

### Modifié
- Description sous le select source clarifie que Google favorise les sources tierces vérifiables.
- Texte du bouton Trustindex (agence #1) mis à jour : positionné comme alternative plutôt que fonctionnalité principale.

### Notes
- **Trustindex reste supporté** comme une des 3 sources (trustindex / google-gmb / manuel). Il n'est ni supprimé ni déprécié — juste plus positionné comme option. Le bouton « 🔄 Synchroniser depuis Trustindex (global) » reste actif sur l'Agence #1.
- **Impact conformité Google** : les sites qui utilisaient `source = 'manuel'` sans le savoir ne verront plus leur `aggregateRating` dans le JSON-LD → moins de risque de pénalité pour schema self-serving, mais potentiellement la perte d'étoiles dans les rich results jusqu'à ce que la source soit corrigée.

### Ajouté — Brique 7.B : avis individuels par agence via Google Business Profile
Remplace **complètement** le besoin de Trustindex payant pour émettre des nœuds `schema.org/Review` individuels. Utilise l'API Google Places qui retourne jusqu'à 5 avis récents par place (auteur, photo, note, texte, date, langue). Même appel API que pour l'aggregate rating → pas de coût supplémentaire (+5 € / 1000 requêtes pour le tier "Atmosphere" négligeable).

#### Ajouté
- **Champ `reviews.items[]`** par agence (v4.0.0 Brique 7.B). Structure par item : `{author, author_url, author_photo, rating, text, date (ISO 8601), language}`. Non éditable via form — populated par cron/AJAX uniquement.
- **Helper `nrs_parse_gbp_reviews(array)`** — normalise la structure Google vers le format interne. Filtre les entrées invalides (sans auteur/texte/rating).
- **Champ `reviews` ajouté à la requête Places API** (`nrs_fetch_agency_from_google_places`) — passe du tier Basic au tier Atmosphere, +~$5/1000 requêtes. Pour 10 agences × 52 semaines = 520 req/an = coût annuel marginal.
- **`nrs_sync_agency_reviews()` stocke désormais `reviews.items`** en plus de rating/count. Le cron hebdo et l'AJAX resync le font automatiquement.
- **Émission `review` array sur LocalBusiness** (`helpers-inject.php`) : chaque item devient un nœud `schema.org/Review` avec auteur `Person` (nom + photo + URL profil Google), `reviewRating`, `reviewBody`, `datePublished`, `inLanguage`. Émis uniquement si l'agence est liée ET source = `google-gmb` (bypass pour source = `manuel`, cohérent avec la défense de Brique 7.A).
- **UI admin** :
  - Compteur **💬 N avis individuels émis en Schema** à côté du freshness indicator.
  - Panneau pliable **« 👁️ Voir les N avis récupérés »** sous le resync button, affichant pour chaque avis : photo + nom auteur + étoiles + date + 300 premiers caractères du texte.
  - Feedback du bouton resync enrichi : *« ✓ Sync OK · 💬 5 avis individuel(s) récupéré(s) »*.

#### Conformité Google / ToS
- Les avis Google sont cachés en DB pour max 7 jours (fréquence du cron) — bien en dessous du plafond ToS de 30 jours.
- Attribution respectée : l'auteur est toujours nommé dans le Review node schema.org (visible par Google).
- Aucune modification du texte ou de la note : snapshot fidèle de ce que Google publie sur sa fiche GBP.

#### Bénéfices
- Rich results étoilés dans les SERPs (Google peut choisir d'afficher une review en snippet).
- Signal Knowledge Graph fort : le business est décrit par des avis vérifiables tiers.
- **Remplace fonctionnellement Trustindex payant** pour la majorité des cas (sauf Yelp/TripAdvisor/autre agrégateur où Trustindex reste pertinent).

---

### Ajouté — Cron mensuel Trustindex (version gratuite)
La version gratuite de Trustindex limite à **1 refresh manuel par mois**. Pour ne plus avoir à y penser :
- **Nouveau schedule custom `monthly`** (30 jours) déclaré via le filter `cron_schedules` dans `reviews-trustindex-sync.php`.
- **Hook `nexxis_rs_cron_sync_trustindex`** planifié automatiquement dans les 2 loaders. Callback : appelle la fonction existante `nrs_sync_trustindex_data()` — même logique que le bouton manuel.
- **Log dans `/nrs-logs/nrs-ia.log`** à chaque exécution (succès ou échec) pour traçabilité.
- **Affichage UX** dans la section « ⭐ Avis Clients (Trustindex) » : nouvelle ligne bleue *« 🔁 Prochaine synchro auto (mensuelle) : dans X jours (date précise) »*.
- Aucune saisie/config requise : le cron démarre dès le déploiement. Si Trustindex n'est pas installé sur le site, le callback retourne une erreur loguée mais ne casse rien.

---

## [4.0.0] — 2026-04-18 — Brique 8 : Admin UX (colonne Schema + Rich Results Test + hooks)

### Ajouté — Colonne « 🎯 Schema » dans la liste des pages/posts
Nouveau fichier `admin-pages-list.php` — activé sur tous les post types supportés (`page`, `post`, et ceux déclarés via `nrs_get_supported_post_types()`). Extensible via `nrs_schema_column_post_types`.

Indicateurs affichés par ligne :
- **Badge coloré** du `@type` émis (Service, LocalBusiness, AboutPage, WebPage, Article, Product, FAQPage, HowTo, Event, Video, JobPosting, etc.)
- **📝** Description présente (≥ 50 caractères excerpt ou nx_service_description renseigné)
- **💬** FAQ activée
- **🏙️ Nom de ville** si `nx_city_context=true`
- **🏢 Nom de l'agence liée** si `nx_agency_id` renseigné, avec **⚠️ + id** si l'agence ne matche plus le registre
- **⚠️ N** nombre de warnings de cohérence (calculés par `nrs_get_page_schema_warnings()`)
- **✓** si configuration cohérente sur un type actif

Colonne positionnée juste après « Titre » pour rester dans le champ de vision.

### Ajouté — Row action « 🧪 Tester Schema »
Lien sur chaque ligne de page publiée qui ouvre le **Google Rich Results Test** dans un nouvel onglet avec l'URL de la page pré-renseignée. Affiché uniquement sur les pages en `publish` (sinon l'URL n'est pas atteignable par Google).

### Ajouté — Bouton Rich Results Test dans le metabox
Le metabox « Nexxis Snippet Helper » affiche désormais un bouton **« 🧪 Tester dans Rich Results Test »** à côté de « Copier le JSON ». Même logique : uniquement si la page est publiée.

### Ajouté — Filter hooks d'extensibilité (helpers-inject.php)
Trois nouveaux points d'extension pour les développeurs tiers :
- **`nexxis_rs_organization_node`** — filtre le nœud `Organization` juste avant son ajout au graphe. Permet d'injecter `duns`, `leiCode`, des `sameAs` dynamiques, etc.
  ```php
  apply_filters('nexxis_rs_organization_node', $organization_node, $opts, $post_id);
  ```
- **`nexxis_rs_page_main_node`** — filtre le nœud principal (Service, LocalBusiness, Article, Event, Product, etc.) avant ajout. Reçoit aussi le type et l'agence liée éventuelle.
  ```php
  apply_filters('nexxis_rs_page_main_node', $graph_main, $type, $linked_agency, $post_id);
  ```
- **`nexxis_rs_page_graph`** — filtre final sur le `@graph` complet avant sérialisation + cache. Utile pour ajouter des nœuds custom (Person, Offer, Course…), fusionner avec d'autres plugins schema ou retirer des nœuds conditionnellement.
  ```php
  apply_filters('nexxis_rs_page_graph', $graph, $post_id);
  ```

### Modifié
- [class-rich-snippets.php](class-rich-snippets.php) + [nexxis-rich-snippets.php](nexxis-rich-snippets.php) — loader mis à jour pour inclure `admin-pages-list.php`.

### Notes
- La colonne Schema est **passive** — purement informative, n'affecte pas le save ni le rendu.
- Les indicateurs sont recalculés à chaque affichage de la liste — si perf critique sur grosses bases, filtrer `nrs_schema_column_post_types` pour limiter aux post types prioritaires.
- Les 3 filter hooks respectent le principe WordPress standard : retourner l'array modifié.

---

## [4.0.0] — 2026-04-18 — Brique 6 : AboutPage + protection city pages renforcée

Deux enrichissements complémentaires : un nouveau type de page `AboutPage` pour signaler explicitement les pages institutionnelles, et un système de warnings admin qui remonte les configurations risquées à l'utilisateur plutôt que de les corriger silencieusement.

### Ajouté — Types de page AboutPage + WebPage
- **`aboutpage`** dans le select `nx_schema_type` (ACF) — destiné aux pages « À propos », « Qui sommes-nous ». Rendu : le nœud WebPage devient `@type: AboutPage` + propriété `about: { @id: #organization }` (signal explicite pour le Knowledge Graph).
- **`webpage`** ajouté aussi pour les pages génériques qui veulent déclarer `WebPage` explicitement (sans schéma principal type Service/LocalBusiness).
- La HP conserve son `about → #organization` automatique (comportement préservé).

### Ajouté — Warnings de cohérence par page (metabox)
Nouvelle fonction `nrs_get_page_schema_warnings($post_id)` dans `helpers.php`, intégrée dans le metabox « Nexxis Snippet Helper ». Règles implémentées :
- **R1 — Fausse NAP évitée** : `city_context = true` + `nx_schema_type = localbusiness` + aucune agence liée → warning explicite expliquant que le rendu bascule automatiquement en `Service` et suggérant d'éditer le type explicitement.
- **R2 — Agence liée manquante** : `nx_schema_type = localbusiness` + mode Multi + ≥2 agences + aucune agence sélectionnée → warning recommandant de choisir une agence pour éviter le fallback gmb_* miroir.
- **R3 — AboutPage sans description** : AboutPage avec excerpt < 50 caractères → info suggérant d'enrichir.

### Modifié — Notices admin globales (`qa-lint.php`)
Refonte enrichie avec nouvelles règles v4 :
- **Profil non configuré** alors qu'au moins une agence existe (incohérence après migration partielle).
- **Mode Multi avec <2 agences** — suggère de repasser en Solo ou d'ajouter une 2e agence.
- **Agences françaises sans SIRET** — liste les agences FR concernées, suggère l'import Registre FR.
- **Identifiant juridique global vide** alors que des agences existent — invite à renseigner Identité juridique.
- Messages existants mis à jour pour pointer vers les sections v4 (Infos Contact Toolkit, Agence #1, etc.).

### Rendu JSON-LD pour une AboutPage
```json
{
  "@type": "AboutPage",
  "@id": "https://nexxis.fr/a-propos/#webpage",
  "name": "À propos de Nexxis",
  "about": { "@id": "https://nexxis.fr/#organization" },
  "isPartOf": { "@id": "https://nexxis.fr/#website" },
  "publisher": { "@id": "https://nexxis.fr/#organization" },
  ...
}
```

### Notes
- Les warnings de cohérence **ne bloquent pas** le rendu — ils informent l'admin. Le rendu reste défensif (Brique 4 : bascule silencieuse en Service si fausse NAP potentielle).
- Le metabox reste pertinent même pour les pages sans problème : l'extrait JSON-LD complet est toujours affiché en dessous.

---

## [4.0.0] — 2026-04-17 — Brique 5 : Organization HP enrichie (subOrganization, areaServed, makesOffer)

Dernière brique qui enrichit le nœud `Organization` global. La HP devient un véritable **hub d'entité** pour Google : lie toutes les agences physiques, déclare le périmètre géographique couvert par l'entreprise, et expose le catalogue de services proposés.

### Ajouté — Nouvelle section admin « 🌍 Zones & Catalogue global »
Placée entre « 🏛️ Identité juridique » et « 🏢 Agences physiques ». Contient 2 repeaters globaux (niveau Organization, pas par agence) :
- **Zones géographiques couvertes (`areaServed`)** — repeater de `{type, name}` avec 4 types schema.org supportés :
  - `AdministrativeArea` (région / département / état)
  - `City` (ville)
  - `Country` (pays)
  - `PostalCodeArea` (code postal)
  - Filtrable via `nrs_area_served_types` pour ajouter des types custom sans fork.
- **Catalogue de services (`makesOffer`)** — repeater de `{name, description, serviceType}` qui devient un tableau d'`Offer` → `Service` imbriqués sur l'Organization.

### Ajouté — Helpers PHP exposés (`helpers.php`)
- `nrs_get_area_served_types()` — liste des 4 types + filtre.
- `nrs_sanitize_area_served(array)` — whitelist type + nom non-vide obligatoire.
- `nrs_sanitize_offers(array)` — nom obligatoire + description/serviceType optionnels (wp_strip_all_tags sur description).

### Modifié — Rendu JSON-LD (helpers-inject.php)
Le nœud `Organization` reçoit 3 nouvelles propriétés conditionnelles :
- **`subOrganization`** — **auto-généré** en mode Multi avec ≥2 agences. Tableau de références `{@id: site/#lb-{agency-slug}}` pointant les LocalBusiness de chaque agence. Complément miroir du `branchOf` émis côté LocalBusiness (Brique 4). En mode Solo ou Service-area, non émis (pas de hiérarchie).
- **`areaServed`** — injecté depuis `areaServed_global` si non vide. Tableau de `{@type, name}`.
- **`makesOffer`** — injecté depuis `makesOffer_global` si non vide. Tableau d'`Offer` avec `itemOffered` imbriqué (`Service` node).

### Exemple de rendu complet (mode Multi)
```json
{
  "@type": "Organization",
  "name": "Nexxis",
  "legalName": "NEXXIS",
  "identifier": { "@type": "PropertyValue", "propertyID": "SIRET", "value": "94944501900010" },
  "vatID": "FR06949445019",
  "subOrganization": [
    { "@id": "https://nexxis.fr/#lb-paris" },
    { "@id": "https://nexxis.fr/#lb-lyon" }
  ],
  "areaServed": [
    { "@type": "AdministrativeArea", "name": "Île-de-France" },
    { "@type": "AdministrativeArea", "name": "Auvergne-Rhône-Alpes" }
  ],
  "makesOffer": [
    {
      "@type": "Offer",
      "itemOffered": { "@type": "Service", "name": "Audit SEO", "serviceType": "SEO" }
    },
    {
      "@type": "Offer",
      "itemOffered": { "@type": "Service", "name": "Refonte site web" }
    }
  ]
}
```

### Notes
- `subOrganization` s'active automatiquement dès que profile=`multi` et agences ≥ 2 — aucun réglage à faire.
- `areaServed` global est **distinct** des areaServed par agence (qui restent sur les pages Service liées).
- `makesOffer` global ne supplante pas les pages Service individuelles — c'est un catalogue synthétique au niveau Organization.

---

## [4.0.0] — 2026-04-17 — Rayon d'intervention par agence

### Migration auto v4.0.0 → v4.1.0
- Versioning du schéma d'options poussé à **4.1.0** (nouveau palier `nrs_schema_version`).
- **Migration transparente** : si `radius_km` global est renseigné ET que l'Agence #1 n'a pas encore de rayon propre, la valeur est **copiée automatiquement** au premier chargement post-déploiement. Pas d'intervention utilisateur requise.
- Refacto de `nexxis_rs_maybe_migrate_to_v4()` en pipeline de migrations versionnées (legacy→4.0.0, puis 4.0.0→4.1.0). Idempotente : chaque palier ne s'exécute que si nécessaire.
- Log enrichi via `nrs_ia_log` : trace `from_version`/`to_version` + `v41_radius_moved` pour suivi.

### Ajouté — Rayon par agence
- **Nouveau champ « Rayon d'intervention (km) »** dans la section 🗺️ Géolocalisation de chaque agence.
- Permet en mode Multi d'avoir des zones d'intervention distinctes par établissement (ex : Paris couvre 30 km, Lyon 50 km).
- Ajouté à `nrs_empty_agency()` et `nrs_sanitize_agency()` (validation : numérique ≥ 0, vide accepté).

### Modifié — Rendu `areaServed`
- `nexxis_rs_build_area_served_array()` refondu :
  - Itère les agences qui ont `geo.latitude`/`longitude` + `radius_km` → génère un `GeoShape` circulaire par agence.
  - Fallback legacy : si **aucune agence** n'a son rayon propre, applique le rayon global autour de la géo miroirée depuis l'Agence #1 (`gmb_lat`/`gmb_lng`).
- Résultat en mode Multi : plusieurs cercles `GeoShape` dans `areaServed` → service-area business correctement décrit pour Google.

### Modifié — Rayon global déprécié mais conservé
- Champ « Rayon (km) » renommé « **Rayon d'intervention global (km) [fallback]** » dans 🏢 Données Entreprise.
- Notice jaune explicite : *« Fallback legacy — privilégie désormais le rayon par agence. Ce champ n'est utilisé que si aucune agence n'a son rayon propre. »*
- Préservé en DB pour rétrocompat, pas de migration automatique (l'utilisateur peut décider de re-saisir par agence ou garder le global).

---

## [4.0.0] — 2026-04-17 — Identifiant global → SIRET par défaut + prefill agence #1

### Modifié
- **Auto-fill Identité juridique (Registre FR)** : l'identifiant stocké est désormais le **SIRET du siège** (14 chiffres) plutôt que le SIREN (9 chiffres). Raisons :
  - Le SIRET **contient** le SIREN (les 9 premiers chiffres) → zéro perte d'info.
  - Il identifie précisément le siège social, entité décrite dans le nœud `Organization`.
  - Réutilisable en l'état pour l'Agence #1 (siège social) → évite une double saisie.
  - Fallback SIREN si l'API ne renvoie pas de SIRET.
- **TVA intracom** : toujours calculée depuis le SIREN (9 premiers chiffres du SIRET si besoin).

### Ajouté — Auto-prefill SIRET de la 1ère agence
- À la création de la **1ère agence** (donc l'établissement principal par convention), son SIRET est **automatiquement prérempli** depuis l'identifiant global si celui-ci est un SIRET 14 chiffres valide. Flash vert sur le champ prérempli.
- N'écrase pas une valeur déjà saisie. Ne se déclenche que pour la 1ère agence (la 2e et suivantes doivent importer leur propre SIRET via « 🇫🇷 Depuis le Registre FR »).

---

## [4.0.0] — 2026-04-17 — Intégration module `contact-info` comme source de vérité

Rich Snippets délègue désormais les données de contact globales (téléphone, email) au module Nexxis Toolkit `contact-info`, qui gère déjà une cascade propre Toolkit → Customizer Enfold → Options Enfold. Fin de la duplication.

### Modifié
- **`nexxis_rs_get_phone_raw()`** — priorité à `Nexxis_Module_Contact_Info::get_phone()`, fallback legacy `$opts['phone_number']` puis `enfold_phone_number`.
- **`nexxis_rs_get_phone_e164()`** — priorité à `Nexxis_Module_Contact_Info::get_phone_e164()` (qui gère la conversion propre avec indicatif pays), fallback legacy.
- **`nexxis_rs_get_email()`** — priorité à `Nexxis_Module_Contact_Info::get_email()`, fallback legacy `$opts['email']` puis `admin_email` WP.
- Toutes ces fonctions vérifient d'abord `class_exists('Nexxis_Module_Contact_Info')` pour rester compatibles si le module contact-info est désactivé.

### Ajouté — Auto-fill fallback depuis `contact-info`
- **`nrs_agency_fill_contact_fallback(array &$agency)`** : nouveau helper qui complète `phone`/`email` d'une agence vides depuis le module global.
- **`nrs_fetch_agency_from_google_places()`** : appelle le fallback après le fetch. Google fournit généralement le phone mais pas l'email → `email` est désormais auto-rempli.
- **`nrs_fetch_agency_from_recherche_entreprises()`** : appelle le fallback pour chaque candidat. L'API gouv ne fournit ni phone ni email → les deux sont désormais auto-remplis.
- `nrsPopulateAgencyBlock()` JS consomme désormais aussi `data.email` pour afficher le flash vert sur le champ rempli.

### Supprimé
- **Champ « Email » global** dans la section « 🏢 Données Entreprise » retiré de l'UI. L'email au niveau Organization est désormais piloté par le module `contact-info`. Les agences ont chacune leur email éditable par ailleurs.
- Save handler : ne lit plus `$raw['email']`, préserve la valeur DB existante pour rétrocompat.

### Notes
- **Aucune perte de données** : les valeurs `email` et `phone_number` en DB sont préservées (lecture encore possible en fallback ultime).
- **Workflow type** : l'utilisateur remplit une fois **📞 Infos Contact** au niveau Toolkit → ces infos se propagent automatiquement sur tout import d'agence (Google Places, Registre FR) sans saisie manuelle.
- Le module contact-info reste **optionnel** : si désactivé, les helpers basculent sur le fallback legacy sans casse.

---

## [4.0.0] — 2026-04-17 — Nettoyage : section GMB supprimée, SEOPress découplé

Grand ménage post-Brique 4 : le registre d'agences remplace intégralement la section GMB historique, et le module devient **fully autonomous** vis-à-vis de SEOPress PRO.

### Supprimé — Section « 📍 Données Établissement (GMB) »
- **Entête + 15 champs + repeater horaires** retirés de la page de réglages. Toutes les éditions passent désormais par le registre « 🏢 Agences physiques » (Agence #1 = l'établissement principal en mode Solo).
- Boutons UI et handlers JS correspondants supprimés :
  - `#nrs-sync-gmb` (remplacé par « 📍 Depuis Google Places » dans chaque agence — Brique 2.B)
  - `#nrs-import-seopress` (obsolète : plus de dépendance SEOPress)
- **AJAX handlers retirés** : `wp_ajax_nrs_sync_from_gmb`, `wp_ajax_nrs_import_from_seopress`.
- **Save handler simplifié** : les `$data['gmb_*']` sont préservés depuis la DB puis écrasés par `nrs_mirror_first_agency_to_gmb()` en fin de save. Aucune écriture depuis `$raw[gmb_*]` (le form ne contient plus ces champs).

### Supprimé — Couplage SEOPress
- **`nrs_sync_from_gmb()`** : fonction PHP retirée de `helpers.php` (~80 lignes). La récupération Google Places est désormais par agence via `nrs_fetch_agency_from_google_places()`.
- **`nrs_import_from_seopress()`** : retirée (~50 lignes). Les sites migrant depuis SEOPress utilisent désormais l'import « 🇫🇷 Registre FR » par agence.
- **`nexxis_rs_get_coords_from_seopress()`** : retirée (fallback obsolète pour les coords globales).
- **`nexxis_rs_get_seopress_address()`** : retirée (fallback obsolète pour l'adresse).
- **Fichier `seopress-vars.php`** : supprimé physiquement + retiré des deux loaders (`class-rich-snippets.php` et `nexxis-rich-snippets.php`).
- **Fallback `seopress_social_knowledge_name`** retiré de `helpers-inject.php` — le nom de l'Organization vient du bloginfo WP, la raison sociale du champ `legalName` (Brique 3).
- **Fallback logo SEOPress** (`knowledge_graph_logo`) retiré de `nexxis_rs_get_logo()` — le logo vient désormais du Customizer WP (`custom_logo`) ou du `site_icon` en fallback.
- **Message QA** actualisé : "Logo manquant... (Apparence > Personnaliser > Identité du site)" au lieu de référer à SEOPress.

### Conservé (défensif, 5 lignes au total)
- **Kill switch** `remove_action('wp_head', 'seopress_schemas_json_ld')` — empêche le double JSON-LD si SEOPress est encore installé sur le site.
- **Menu placement fallback** `class_exists('SEOPress')` — place la page réglages en sous-menu SEOPress si présent (cosmétique, rétrocompat parc existant).

### Impact
- Module **100 % autonome** vis-à-vis de SEOPress PRO pour le fonctionnement.
- UI nettement simplifiée : -1 section majeure, -2 boutons d'import globaux (logique déplacée par agence).
- **~400 lignes retirées** (HTML + PHP + JS + 1 fichier complet).
- **Aucune régression** : les options `gmb_*` restent en DB (écrites par le mirror), le rendu JSON-LD les lit toujours en fallback.

---

## [4.0.0] — 2026-04-17 — Brique 4 : Liaison page ↔ agence + branchOf

Le **rendu JSON-LD devient enfin pleinement multi-agences**. Chaque page `LocalBusiness` ou `Service` peut être liée à une agence du registre, et le schéma est généré depuis ses propres NAP, sub-type, horaires, avis, SIRET. Le nœud `Organization` global reste la racine du graphe via `branchOf`.

### Ajouté — Champ ACF `nx_agency_id`
- Select visible sur les pages de type `service` ou `localbusiness`.
- Choix peuplé dynamiquement depuis le registre via `nexxis_rs_get_agencies_for_acf()`.
- Option vide « Aucune liaison » possible (fallback Organization / gmb_* legacy).
- Recommandé en mode Multi ; en mode Solo, toujours ignoré (agencies[0] est l'agence par défaut).

### Ajouté — Helpers PHP de résolution
- `nrs_get_agency_by_id($id)` — retourne l'entrée complète (fusionnée avec `nrs_empty_agency` pour structure sûre) ou null.
- `nrs_get_agency_for_post($post_id)` — résolution en 3 étapes : (1) ACF `nx_agency_id`, (2) mode Solo → agencies[0], (3) null (fallback legacy).

### Modifié — Rendu JSON-LD (helpers-inject.php)
Refonte complète du bloc SERVICE/LOCAL BUSINESS :
- **`@type` dynamique** : si une agence est liée et a un `schemaType` précis (ex: `HVACBusiness`, `ProfessionalService`, `Plumber`), c'est ce type qui est émis — plus de `LocalBusiness` générique par défaut.
- **`@id` stable par agence** : un LocalBusiness avec agence liée utilise `@id = site/#lb-{agency-slug}` → toutes les pages pointant la même agence partagent la même entité (fusion correcte côté Google).
- **`branchOf: { @id: #organization }`** systématiquement ajouté sur chaque LocalBusiness — signal fort pour le Knowledge Graph.
- **NAP depuis l'agence liée** : `telephone`, `email`, `address`, `geo`, `hasMap`, `openingHoursSpecification`, `priceRange` — tout vient de l'agence. Fallback `gmb_*` si aucune liaison.
- **`identifier` PropertyValue SIRET** : le SIRET de l'agence est émis comme identifiant officiel schema.org.
- **`aggregateRating` par agence** : priorité (1) avis locaux page → (2) avis agence → (3) globaux legacy. Plus de note globale injectée si l'agence a ses propres avis.
- **Provider des Services** : si la page Service est liée à une agence, `provider = { @id: #lb-{agency-slug} }` (au lieu de l'Organization générique) → signal fin que le service est fourni par cette agence précise.

### Ajouté — Protection city pages (fausse NAP)
Si une page a `nx_city_context = true` (zone d'intervention) ET `nx_schema_type = localbusiness` ET **aucune agence liée**, le rendu **bascule silencieusement en `Service`** au lieu d'émettre un LocalBusiness sans adresse valide (risque de pénalité Google + incohérence du graphe). Logué via `nrs_ia_log` si `NXT_DEBUG` actif pour suivi. La vraie validation UX (notice admin + QA lint) viendra en Brique 8.

### Notes
- Aucune régression : les pages sans liaison ni mode Solo continuent de rendre depuis les `gmb_*` (comportement pré-v4 préservé).
- Le champ `nx_agency_id` n'est pas obligatoire — c'est une sélection optionnelle qui enrichit le rendu quand présente.

---

## [4.0.0] — 2026-04-17 — Brique 3 : Identité juridique enrichie

Première brique à **injecter visiblement en front** depuis le début de la refonte v4. Le nœud `Organization` du JSON-LD reçoit 5 nouvelles propriétés standard schema.org, ce qui renforce significativement les signaux E-E-A-T et la probabilité d'apparaître dans le Knowledge Graph de Google.

### Ajouté — Section admin « 🏛️ Identité juridique »
Nouvelle section globale placée entre « Profil & Localisation » et le registre d'agences :
- **`legalName` (Raison sociale)** — distincte de `name` (marque commerciale).
- **`slogan`** — avec fallback automatique vers `get_bloginfo('description')` si vide.
- **`identifier`** (PropertyValue) — select de type + value. 13 types curés dans `nrs_get_identifier_types()` :
  - 🇫🇷 SIREN (9), SIRET (14)
  - 🇪🇺 TVA intracommunautaire
  - 🇧🇪 BCE/KBO, 🇨🇭 UID/IDE, 🇳🇱 KvK, 🇩🇪 HRB, 🇪🇸 CIF, 🇮🇹 Partita IVA, 🇵🇹 NIF
  - 🇺🇸 EIN, 🇬🇧 UK Company Number
  - 🌍 Autre (texte libre)
- **`vatID`** — numéro de TVA intracommunautaire (propriété dédiée schema.org).
- **`awards`** — repeater de certifications/prix (libellé + URL de vérification). Crucial pour les artisans (RGE, Qualibat, Qualipac, NF Service…). Signal fort de crédibilité.

### Ajouté — SIRET par agence
Nouveau champ `siret` (14 chiffres) ajouté dans la sous-section « Identifiants & détails » de chaque agence.
- **Auto-rempli automatiquement** par l'import Registre FR (Brique 2.B) : la fonction `nrs_fetch_agency_from_recherche_entreprises()` retourne désormais `siret` dans la structure `agency`.
- Côté JS, `nrsPopulateAgencyBlock()` consomme `data.siret` et renseigne le champ.
- Validation stricte : strippe tout non-chiffre, valide la longueur exacte à 14.

### Modifié — Rendu JSON-LD (helpers-inject.php)
Le nœud `Organization` s'enrichit de 5 propriétés conditionnelles (émises uniquement si renseignées) :
- `legalName`
- `slogan` (saisie ou fallback blogdescription WP)
- `identifier` : `{ @type: PropertyValue, propertyID: "SIREN", value: "123456789" }`
- `vatID`
- `award` : tableau des libellés des certifications

**Note** : l'URL de vérification des awards est stockée en DB pour référence admin mais n'est pas émise en JSON-LD (schema.org/award n'accepte pas d'URL dans sa forme simple). Amélioration possible en v4.x+ via `Certification` ou `Grant` nodes.

### Helpers PHP exposés (`helpers.php`)
- `nrs_get_identifier_types()` — liste des types d'identifiants (filtrable via `nrs_identifier_types`)
- `nrs_sanitize_identifier(array)` — sanitize type + value (strip espaces pour numériques)
- `nrs_sanitize_awards(array)` — sanitize la liste d'awards, supprime les entrées vides
- Extension de `nrs_empty_agency()` et `nrs_sanitize_agency()` pour le champ `siret`

### Notes
- Le SIRET par agence n'est **pas encore injecté en front** sur le nœud LocalBusiness — ce sera fait en Brique 4 quand la liaison page ↔ agence sera active.
- Aucune régression : les options existantes ne sont pas modifiées, les nouveaux champs sont optionnels.

### Correctifs UX (patch Brique 3)
#### Contrainte mode Solo
- **Bouton « ➕ Ajouter une agence »** et **boutons groupés « Tout replier / déplier »** désormais **masqués en mode Solo** (profile = `solo`). Un message explicatif remplace le bouton : « 🏪 Mode Solo — l'ajout de plusieurs agences est désactivé. Passe en mode Multi si tu as plusieurs établissements. »
- **Notice de sécurité des données** si mode Solo ET plusieurs agences existent déjà (cas : l'utilisateur a créé N agences puis est passé en Solo). Message : « Seule l'Agence #1 est injectée dans le JSON-LD tant que le profil reste Solo. Les agences suivantes sont préservées en DB mais ignorées en rendu. » Aucune suppression de données, tout reste restaurable en repassant en Multi.

#### Import depuis le Registre FR pour l'Identité juridique globale
- **Nouveau bloc « 📥 Import rapide »** en tête de la section « 🏛️ Identité juridique » avec input SIREN/SIRET/nom + bouton **« 🇫🇷 Depuis le Registre FR »**.
- Réutilise l'AJAX handler existant `nrs_agency_search_inpi` (pas de nouvelle route backend).
- **Auto-fill intelligent** des 3 champs principaux :
  - `legalName` ← `nom_complet` depuis l'API gouv (forcé, écrase une saisie manuelle)
  - `identifier.type` ← `SIREN` (automatique)
  - `identifier.value` ← SIREN 9 chiffres (forcé)
  - `vatID` ← **calculé automatiquement** via la formule officielle française : `FR` + clé + SIREN avec `clé = (12 + 3 × (SIREN mod 97)) mod 97`. Utilise `BigInt` côté JS pour éviter toute perte de précision. Calculé uniquement si le champ est vide (préserve une saisie manuelle).
- **Picker multi-résultats** : si l'API retourne plusieurs entreprises, liste de cartes cliquables avec SIREN/SIRET/adresse, bouton « Utiliser » par ligne.
- Nouveaux helpers JS exposés : `nrsComputeFrVat(siren)`, `nrsPopulateGlobalIdentity(displayData)`.

---

## [4.0.0] — 2026-04-17 — Brique 2.B (auto-fill) : Google Places + Registre FR

Premier volet de la Brique 2.B : l'auto-fill depuis deux sources pour éviter la saisie manuelle de 20 champs par agence.
Les sous-parties restantes de 2.B (IA sub-types, horaires par agence, reviews par agence) arriveront ensuite.

### Ajouté
- **Bloc « 📥 Import rapide »** en tête de chaque agence (existante + template) avec 2 workflows :
  - **📍 Google Place ID** → appel direct à Google Places Details API, retour d'un candidat unique, auto-fill des champs NAP + géo + horaires + fourchette de prix + Place ID.
  - **🇫🇷 Registre FR** (SIREN / SIRET / nom) → appel à l'API gouv.fr ouverte `recherche-entreprises.api.gouv.fr` (gratuite, sans clé). Retour de 0 à 5 candidats avec picker si plusieurs.
- **Helpers PHP exposés** (`helpers.php`) :
  - `nrs_fetch_agency_from_google_places($place_id, $api_key)` — wrapper qui retourne un candidat au format `nrs_empty_agency()` sans écrire en DB.
  - `nrs_fetch_agency_from_recherche_entreprises($query)` — interroge l'API gouv, parse les SIREN/SIRET/adresse/date de création/tranche d'effectifs, retourne jusqu'à 5 candidats.
- **Nouveaux AJAX handlers** : `nrs_agency_fetch_google_places`, `nrs_agency_search_inpi` (tous deux protégés par nonce `nexxis_rs_ajax` + `manage_options`).
- **Helpers JS** : `nrsFindByNameSuffix`, `nrsFlashInput`, `nrsPopulateAgencyBlock(data)` — populate les champs imbriqués (`[address][...]`, `[geo][...]`) d'un bloc agence via leur suffixe de `name="..."`.
- **Flash visuel vert** sur chaque champ auto-rempli (pattern réutilisé depuis la refonte villes).
- **Picker pour résultats multiples** : cartes cliquables listant nom, SIREN, SIRET, adresse complète, tranche d'effectifs. Bouton « Utiliser » par ligne → populate.

### Notes
- L'auto-fill **n'enregistre rien** : l'utilisateur visualise les champs remplis et valide par « Sauvegarder » global. Permet de corriger avant commit.
- La clé Google Places utilise la logique existante `nrs_get_gplaces_key()` (constante wp-config.php prioritaire, puis admin, puis env var).
- Les reviews Google récupérées en même temps (rating/count) sont affichées en info dans le feedback mais ne sont pas encore injectées tant que la 2.B reviews n'est pas livrée.
- La tranche d'effectifs INSEE est convertie en valeur numérique approximative via une table de mapping (`NN/00/01/02/...`).

### Brique 2.B — Fin : IA sub-types + Horaires par agence + Reviews par agence
Finalise la Brique 2. Chaque agence est désormais éditable en profondeur (identité, contact, adresse, géo, identifiants, description, horaires, avis) avec assistance IA pour le typage Schema.org.

#### Ajouté — IA sub-types Schema.org
- **Nouveau champ `description`** dans chaque agence (textarea 500 chars max) qui alimente l'IA et le futur schema `description`.
- **Bouton « 🤖 Suggérer via IA »** à côté du champ `schemaType` : POST vers OpenAI (modèle configuré dans les réglages) avec nom agence + description agence + URL + pays + **knowsAbout global + nom/slogan WP** → retour JSON `{"types":[...], "reasoning":"..."}`.
- **Sources multiples croisées** : l'IA agrège toutes les infos disponibles (per-agence ET globales). Utile quand l'utilisateur n'a pas rempli la description d'agence mais a déjà renseigné `knowsAbout` au niveau entreprise. Le prompt inclut aussi le nom et le slogan WordPress.
- **Prompt spécialisé** : indication explicite au modèle de privilégier `ProfessionalService` / `WebDesignBusiness` / `Organization` pour agences digitales/SEO/marketing (cas où les sous-types LocalBusiness classiques ne conviennent pas).
- **Validation souple** : suffit d'avoir un nom OU une description OU un knowsAbout global pour déclencher l'IA (avant il fallait un nom ou une description d'agence, ce qui excluait le cas « knowsAbout seul »).
- **Validation serveur contre le vocabulaire schema.org** (post-IA) : liste curée de ~130 types valides (`LocalBusiness` + tous ses descendants connus + `Organization` et sous-types). Fonctions exposées : `nrs_valid_schema_types()`, `nrs_filter_valid_schema_types()`. Les types inventés par l'IA (ex: `WebDesignBusiness`, `SEOAgency`, `DigitalAgency`) sont **rejetés automatiquement** et signalés dans l'UI avec un badge ⚠️ rouge non-cliquable. Filtre `nrs_valid_schema_types` pour étendre sans fork. Les rejets sont loggués via `nrs_ia_log` pour suivi qualité.
- **Auto-remplacement de `LocalBusiness` générique** : quand l'utilisateur clique sur un badge IA d'un sous-type précis (ex: `ProfessionalService`), le `LocalBusiness` générique est automatiquement retiré du champ. Raison : tous les sous-types descendent de `LocalBusiness` dans schema.org → les cumuler est redondant et dégrade la précision sémantique. Seul le pick de `LocalBusiness` lui-même (très rare depuis l'IA) conserve le comportement append simple.
- Affichage des suggestions en **badges cliquables** — chaque clic ajoute le type au CSV `schemaType` (dédoublonnage).
- Post-validation serveur : caractères non alphanumériques strippés (anti-injection).
- Logging IA réutilisé (`nrs_ia_log`) en cas de réponse JSON invalide.

#### Ajouté — Horaires par agence
- **Sous-section `<details>` « 🕐 Horaires d'ouverture »** dans chaque agence avec mini-repeater (jour / ouverture / fermeture / supprimer).
- **Boutons** : ➕ Ajouter un créneau · 🕐 Remplir 24h/7j · Tout effacer (double-clic de confirmation).
- **Import automatique des horaires** depuis Google Places via l'import rapide existant : le tbody se remplit si vide, le `<details>` s'ouvre pour montrer le résultat.
- **Noms de form indexés** : `nexxis_rs_options[agencies][N][openingHoursOverride][M][dayOfWeek|opens|closes]`.
- **Validation PHP** via `nrs_sanitize_opening_hours()` : whitelist jour + regex HH:MM, format final schema.org (`@type: OpeningHoursSpecification`, `dayOfWeek: https://schema.org/Monday`).
- **Nouveau helper** : `nrs_valid_opening_days()`.

#### Ajouté — Reviews par agence
- **Sous-section `<details>` « ⭐ Avis clients »** avec 3 champs : `source` (Trustindex / Google-GMB / Manuel), `rating` (0-5 step 0.1), `count` (int).
- **Import automatique depuis Google Places** : si l'import rapide récupère rating/count, les champs sont préremplis et la source passe à `google-gmb`.
- **Bouton « 🔄 Synchroniser depuis Trustindex (global) »** (Agence #1 uniquement) : injecte les valeurs `nrs_global_rating` / `nrs_global_review_count` côté JS. Sync individuelle par agence prévue en Brique 7 si besoin.
- **Notice de garde-fou** rappelant que la source « Manuel » est déconseillée (self-serving → risque de pénalité Google).

#### Modifié — mirror agencies[0] → legacy
- `nrs_mirror_first_agency_to_gmb()` étendu pour propager :
  - `openingHoursOverride` vers `gmb_opening_hours_override` (le rendu front reste cohérent).
  - `openingHoursImport` vers `gmb_opening_hours`.
  - `reviews.rating` / `reviews.count` vers `nrs_global_rating` / `nrs_global_review_count`.

#### Modifié — UX
- **Auto-ouverture intelligente des sous-sections** :
  - Horaires : `<details>` ouvert par défaut si au moins un créneau existe, fermé sinon.
  - Reviews : `<details>` ouvert si rating+count sont renseignés.
- **Résumé dans le `<summary>`** : nombre de créneaux pour horaires, note star + count pour reviews.

#### Supprimé
- **Ancien repeater horaires global** dans la section GMB (dépréciée) : remplacé par une notice pointant vers les sous-sections par agence. Les IDs JS (`#nrs-override-*`) subsistent sans cible — handlers devenus inactifs, pas de régression (jQuery silencieux sur selector vide).

### Correctif UX — Collapse/expand des blocs agence (patch)
Problème : avec ≥ 2 agences, tous les blocs restent dépliés et prennent toute la page.
- **Header cliquable** avec chevron `▶ / ▼` qui toggle le bloc. Le clic sur les boutons internes (Supprimer, inputs) ne déclenche pas le toggle.
- **Résumé compact** en mode replié : `CP Ville · téléphone` (extrait auto des champs).
- **Règles d'ouverture auto** :
  - 1 agence → toujours ouverte (pas de régression).
  - 2+ agences → seule la 1ʳᵉ est ouverte au load, les autres repliées.
  - **Ajout** via « ➕ Ajouter une agence » → ouverte automatiquement (UX de saisie).
  - **Import rapide** (Google Places ou Registre FR) sur un bloc replié → déplie le bloc pour montrer le résultat.
- **Boutons globaux « ⊟ Tout replier / ⊞ Tout déplier »** affichés dès qu'il y a 2+ agences.
- Helpers JS ajoutés : `nrsCollapseAgency`, `nrsExpandAgency`, `nrsToggleAgency`, `nrsUpdateAgencySummary`, `nrsRefreshBulkControls`.
- Structure HTML : `.nrs-agency-header` (cliquable) + `.nrs-agency-body` (zone pliable). Le chevron et le résumé vivent dans le header.

---

## [4.0.0] — 2026-04-17 — Brique 2.A : Registre d'agences éditable

Suite de la refonte v4. La Brique 2.A ouvre l'édition du registre multi-agences stocké dans `agencies[]` (préparé en Brique 1).
Cette brique ne modifie **pas le rendu JSON-LD** : un bridge « mirror » aligne `agencies[0]` sur les anciens champs `gmb_*` pour que le front reste cohérent jusqu'à la refonte Brique 4.

### Ajouté
- **Section admin « 🏢 Agences physiques »** (repeater) avec pour chaque agence :
  - Identité : `id` (slug auto-dérivé du nom), `name`, `schemaType` (CSV texte, pré-IA de la Brique 2.B)
  - Contact : `phone`, `email`, `website`
  - Adresse postale : 5 champs (`streetAddress`, `addressLocality`, `postalCode`, `addressRegion`, `addressCountry`)
  - Géolocalisation : `latitude`, `longitude`
  - Identifiants & détails : `placeId`, `priceRange` (€ → €€€€), `foundingDate` (YYYY), `employees`
- **CRUD complet** : ajouter / éditer / supprimer une agence directement dans l'admin.
- **Bouton « ➕ Ajouter une agence »** : clone un `<template>` HTML, remplace `{{INDEX}}`/`{{POSITION}}`, scroll + focus auto sur le champ nom.
- **Suppression avec double-clic de confirmation** (pattern v3.2.5 réutilisé).
- **Slugify automatique** au blur du champ nom : remplit l'id si vide (accents retirés, lowercase, tirets).
- **Renumérotation visuelle** `#1`, `#2`, … après chaque add/remove (indices techniques dans les `name="..."` HTML restent stables).
- **Validation serveur** :
  - Nom obligatoire (sinon agence ignorée au save → permet de « supprimer » en vidant le nom).
  - Id obligatoire + format slug `[a-z0-9\-]+` + unicité. Erreur bloquante → agences DB inchangées.
  - Pays validé contre la liste `nrs_get_country_choices()`.
- **Bridge de compatibilité `agencies[0]` → `gmb_*`** au save (fonction `nrs_mirror_first_agency_to_gmb`) : le front rend toujours du JSON-LD cohérent sans toucher à `helpers-inject.php`. Le mirror s'exécute après le traitement des champs `gmb_*` legacy pour que le registre gagne en cas de conflit.
- **Notice de dépréciation** au-dessus de la section « 📍 Données Établissement (GMB) » : informe que les éditions manuelles y seront écrasées par le mirror.

### Fonctions exposées (`helpers.php`)
- `nrs_empty_agency()` — squelette complet d'une entrée
- `nrs_slugify_agency_id(string)` — dérivation slug depuis un nom
- `nrs_sanitize_agency(array $submitted, array $db_existing)` — nettoie + préserve les champs non éditables (horaires, reviews, areaServed)
- `nrs_validate_agencies(array)` — retourne un array d'erreurs lisibles
- `nrs_mirror_first_agency_to_gmb(array &$data)` — bridge legacy

### Comportement selon le profil (Brique 1)
- **`profile = 'service_area'`** → section registre remplacée par une info « Aucune agence à déclarer, zone d'intervention injectée via `serviceArea` en Brique 5 ».
- **`profile = 'solo'`** → registre affiché avec conseil « une seule agence recommandée ».
- **`profile = 'multi'`** → registre affiché, CRUD complet.

### Non couvert en Brique 2.A (réservé à Brique 2.B)
- Auto-fill via API Recherche Entreprises (FR) / Google Places (international)
- Suggestion IA des sous-types Schema.org
- Horaires d'ouverture par agence (repeater imbriqué)
- Reviews par agence (rating/count/source Trustindex)
- areaServed par agence

---

## [4.0.0] — 2026-04-17 — Brique 1 : Setup & Profil

Première brique de la refonte v4 : registre multi-agences + international-ready.
Cette brique est **silencieuse côté front** : aucun changement du JSON-LD émis. Elle prépare le terrain pour les briques 2-9.

### Ajouté
- **Section admin « 🧭 Profil & Localisation »** (en tête de la page de réglages) avec :
  - **Profil de l'entreprise** : `solo` (1 établissement avec adresse), `multi` (plusieurs agences), `service_area` (artisan itinérant, pas d'adresse physique). Détermine la forme du graphe Schema.org généré par les briques suivantes.
  - **Pays principal** (ISO 3166-1 alpha-2) : 15 pays proposés (FR/BE/CH/LU/MC/CA/US/GB/IE/DE/AT/NL/ES/PT/IT), filtrable via `nrs_country_choices`.
  - **Langue du schéma** : tag IETF (`fr`, `fr-BE`, `en-US`…) avec auto-détection depuis `get_locale()` si vide, filtrable via `nrs_language_choices`.
- **Nouvelle clé d'options `agencies[]`** : registre multi-agences (structure finalisée en Brique 2). Une entrée = un établissement physique avec id/name/schemaType/address/geo/phone/email/openingHours/reviews/areaServed.
- **Nouvelle clé d'options `nrs_schema_version`** : versionnage du schéma d'options pour pilotage fin des migrations futures.
- **Fonctions helpers exposées** : `nrs_get_country_choices()`, `nrs_get_language_choices()`, `nrs_get_effective_language()`, `nrs_get_valid_profiles()`, `nrs_get_profile_label()`.

### Migration automatique (v3 → v4)
- Déclenchée sur `init` et `admin_init` (priorité 5) via `nexxis_rs_maybe_migrate_to_v4()`.
- Idempotente : ne s'exécute que si `nrs_schema_version < 4.0.0`.
- **Conversion `gmb_*` → `agencies[0]`** (slug `principal`) : adresse, téléphone, email, place_id, geo, horaires (import + override), note/avis Trustindex globaux. Tous les champs `gmb_*` sont **préservés** pour rétrocompatibilité — le rendu JSON-LD continue de les lire jusqu'à la Brique 2.
- **Inférence du profil** : `gmb_street` renseigné → `solo` ; téléphone/email sans adresse → `service_area` ; sinon laissé vide (l'utilisateur choisira).
- **Inférence du pays** : `gmb_country` s'il est au format ISO alpha-2, sinon `FR`.

### Modifié
- Version bumpée à **4.0.0** dans `nexxis-rich-snippets.php`, `class-rich-snippets.php`, `helpers.php`, `admin-ui.php`.
- Defaults de `nexxis_rs_get_options()` étendus avec les 5 nouvelles clés (`profile`, `country`, `language`, `agencies`, `nrs_schema_version`).

### Notes
- **Aucun changement visible côté front** : le JSON-LD émis est strictement identique à la v3.2.5. La refonte du rendu interviendra en Brique 2 une fois le registre d'agences pleinement éditable.
- Le profil reste facultatif en Brique 1 : une notice invite l'admin à le choisir mais n'impose rien.

### Correctif UX (patch)
- **Bouton « Récupérer les coordonnées manquantes »** : le tableau des villes se met désormais à jour en temps réel après géocodage (plus besoin de recharger/sauvegarder). Flash visuel vert sur les lignes mises à jour, compteur du bouton rafraîchi, helper XSS-safe `nrsEscapeHtml()` ajouté pour tout rendu dynamique futur.

### Refonte UX gestion des villes (patch)
Double source textarea/DB désynchronisée → 3 boutons clairs, un seul tableau de vérité :
- **« 🔍 Vérifier & enregistrer les villes »** (remplace l'ancien « Vérifier les villes » dry-run) : lit le textarea, appelle l'API pour chaque ville nouvelle ou sans coords, sauvegarde en DB, rebuild le tableau principal en temps réel. Préserve les coords des villes déjà connues (pas d'appel API inutile). Message de succès ventilé : total enregistré · nouvelles géocodées · préservées · échecs.
- **« 🧹 Réinitialiser les coordonnées »** (nouveau) : vide `lat`/`lng`/`place_id` de toutes les villes (garde les noms). Double-clic de confirmation (pattern v3.2.5). Utile pour forcer un re-géocodage en masse (changement d'API BAN ↔ Google Places). Le bouton « Récupérer coordonnées » redevient actif après reset.
- **Indicateur « ⚠️ Modifications non enregistrées »** sur le textarea : écoute l'événement `input`, compare (set case-insensitive) le contenu du textarea avec les noms de villes du tableau principal. S'affiche dès qu'il y a un delta, disparaît à la sauvegarde.
- **Nouveaux AJAX handlers** : `nrs_verify_and_save_cities`, `nrs_reset_cities_coords`. L'ancien `nrs_check_cities` (dry-run) reste en place mais n'est plus appelé par l'UI — conservé pour rétrocompat d'éventuelles intégrations externes.
- **Helpers JS partagés** : `nrsReadTextareaCities()`, `nrsReadDbCities()`, `nrsRefreshGeocodeBtn()`, `nrsRefreshResetBtn()`, `nrsUpdateUnsavedIndicator()` — cohérence d'état garantie entre les 3 boutons.
- **Tableau principal toujours rendu** (même vide via `display:none`) pour permettre au JS d'y injecter des lignes après le 1er save sans reload de page.

### Correctif normalisation (patch)
- **« Vérifier & enregistrer »** ne vérifiait pas les villes quand la case « Normalisation API activée » était décochée : appel API conditionné au flag `use_api_normalize` (hérité du save handler classique). Un clic explicite sur ce bouton traduit une intention de vérifier → l'API est désormais appelée **systématiquement**, indépendamment du flag global (aligné sur le comportement de « Récupérer coordonnées »). Le flag `use_api_normalize` ne pilote plus que le save implicite via le bouton « Sauvegarder » global.
- Message de succès enrichi : mention du type d'API utilisé (BAN/Google Places) dans le bilan.

### Fusion des boutons + fix perte de données au Save global (patch)
- **Cause de la perte de données** : après « Vérifier & enregistrer », la DB contenait les noms canoniques (ex: `Paris 16e Arrondissement`) mais le textarea conservait la saisie brute de l'utilisateur (ex: `paris 16e`). Au prochain clic sur le bouton « Sauvegarder » global, `strcasecmp` ne retrouvait pas la correspondance DB → la ville était considérée nouvelle et ses coords écrasées si `use_api_normalize` était off.
- **Fix** : nouvelle fonction `nrsSyncTextareaFromCities()` appelée après chaque Vérifier/Reset pour rafraîchir le textarea avec les noms canoniques retournés par le serveur. Désync impossible désormais.
- **Fusion des 2 boutons** en un seul : **« 🔍 Vérifier les villes »** remplace à la fois « Vérifier & enregistrer » et « Récupérer les coordonnées manquantes ». Le nouveau bouton unique gère tous les cas :
  - Nouvelle ville saisie → API + save
  - Ville existante sans coords (échec API précédent) → API + save
  - Ville existante avec coords → préservation (pas d'appel API inutile)
- **Suppression du bouton « Récupérer les coordonnées manquantes »** : devenu redondant. Pour forcer une re-vérification, passer d'abord par « 🧹 Réinitialiser les coordonnées ».
- **AJAX backend `nrs_geocode_missing_cities` supprimé** (dead code depuis la fusion). L'action `nrs_check_cities` (dry-run historique) est également retirée de l'UI mais conservée côté backend pour rétrocompat.

---

## [3.2.5] — 2026-04-16

### Corrigé
- **Boutons « Synchroniser depuis Google » et « Importer depuis SEOPress » qui ne fonctionnaient que si DevTools était ouvert.**
  - Cause réelle : Chrome **supprime** les `window.confirm()` natifs appelés dans les handlers `async` + delegated events (il perd la *user activation* entre le clic et le dialog). Symptôme : un warning `"A window.confirm() dialog was suppressed because this page is not the active tab"` visible uniquement avec la console ouverte, sinon silencieux.
  - Fix : `confirm()` remplacé par un **pattern double-clic** (1er clic → le bouton change de label « ⚠️ Cliquer à nouveau pour confirmer », 2e clic dans les 5s → action exécutée). Pattern standard moderne (Stripe, Linear, GitHub pour les actions destructives). Non bloqué par les navigateurs, pas de popup intrusive.

---

## [3.2.4] — 2026-04-16

### Ajouté
- **Protection anti-Cloudflare Rocket Loader** : attribut `data-cfasync="false"` sur le `<script>` inline de l'admin. Cloudflare ne défère plus ce script.
- **Fail-safe sur `nrs_encrypt()`** : si le chiffrement échoue (libsodium indisponible ou clé WP modifiée), la clé existante est **préservée** au lieu d'être écrasée par une chaîne vide. Notice d'erreur explicite affichée à l'admin.

### Modifié
- **`nexxis_rs_get_options()` applique `wp_unslash()` récursif** à l'array retournée depuis la DB → nettoie automatiquement les données déjà polluées par des `\'` (venant d'avant le fix wp_unslash au save). Pas besoin de re-saisir les champs : les slashes résiduels disparaissent au prochain chargement.

---

## [3.2.3] — 2026-04-16

### Corrigé
- **Clé OpenAI qui disparaissait après un second enregistrement.**
  - Cause : le champ input est masqué en UI quand la clé est déjà configurée (sécurité). Donc à la sauvegarde suivante, `$_POST['openaiapikey']` n'existait pas → `nrs_encrypt('')` écrasait la clé existante par une chaîne vide.
  - Fix : la clé n'est touchée que si l'utilisateur en a explicitement soumis une nouvelle. Sinon, l'existant est préservé.

- **Apostrophes échappées affichées comme `\'`** (ex: `Provence-Alpes-Côte d\'Azur`) :
  - Cause : WordPress ajoute automatiquement des slashes sur `$_POST` (magic quotes). Sans `wp_unslash()` avant `sanitize_text_field()`, les slashes restaient dans la DB et s'affichaient au rendu.
  - Fix : `wp_unslash()` appliqué dès l'entrée du save handler sur tout `$_POST[NEXXIS_RS_OPT]`.

- **Clics GMB / SEOPress ne fonctionnant qu'avec DevTools ouvert (première approche)** :
  - Fix intermédiaire : click handlers convertis en *delegated events* (`$(document).on('click', '#id', ...)`) pour survivre aux plugins qui modifient le DOM après page load.
  - *Note : cette version a introduit le bug du `confirm()` supprimé, résolu en v3.2.5.*

---

## [3.2.2] — précédente version stable

- BLOC 3.7 : réorganisation sections (GMB en 2e), suppression doublon `phone_number`, bouton 24h/7j pour les horaires.
- Chiffrement `sodium_crypto_secretbox` avec préfixe `nrs_enc:` pour la clé OpenAI stockée en DB.
- Mémoïsation de `nexxis_rs_get_options()` via `static $cache`.
- Priorité de lecture de la clé : constante `NEXXIS_RS_OPENAI_KEY` (wp-config.php) → variable d'environnement `OPENAI_API_KEY` → DB chiffrée.
