# autressites.md : registre des modifications de structure

Ce site (fflv2) sert de **modèle** à d'autres sites de l'écosystème Yggdrasil.
Chaque modification de **structure** (HTML des pages, CSS partagé, JS du site,
modules du back-office, API publiques) est consignée ici pour pouvoir être
**reportée sur les sites copiés**. Les changements de contenu pur (textes,
images, réglages en base) ne sont pas listés.

Format d'une entrée : date · fichier(s) touché(s) · quoi · comment reproduire.

---

## 2026-07-23 : Bandeau « l'appli du festival » en haut de toutes les pages

- **Fichiers** : `resources/views/site/bandeau-appli.blade.php`,
  `HomeController::bandeauAppli()` (injection après `<body>`),
  `Admin/SettingController` + `resources/views/admin/site-settings.blade.php`
  (réglages `app_banner`, `app_banner_url`),
  `tests/Feature/BandeauAppliTest.php`.
- **Quoi** : un bandeau fixe en haut de page, injecté par le SERVEUR après
  `<body>` : une seule écriture pour les seize pages du site. L'en-tête
  (`header{position:fixed;top:0}`) est repoussé par
  `body.has-appbar header{top:var(--appbar-h)}`, la variable étant
  renseignée en JavaScript à la hauteur RÉELLE du bandeau (elle change
  avec la largeur et la taille de police du visiteur). Fermeture
  mémorisée 30 jours en `localStorage`.
- **Reproduire** : copier la vue et la méthode d'injection ; ne pas
  écrire le bandeau dans chaque page HTML.

## 2026-07-23 : Un seul geste recharge TOUS les caches

- **Fichiers** : `app/Services/Resynchronisation.php` (le travail),
  `Admin/ResyncController` + les actions `import()` de Festorga, Orgexpo,
  Photographes, Guests, Planning et Plan (toutes délèguent),
  `tests/Feature/ResyncPartoutTest.php`.
- **Quoi** : les sources se citent les unes les autres (le programme
  nomme des activités La Comte et des invités Guest, le plan nomme des
  exposants Orgexpo et des scènes du programme). Recharger un seul cache
  laissait le site incohérent. RÈGLE : tout bouton de rechargement d'un
  connecteur relance TOUS les imports, dans l'ordre sources puis
  programme puis plan, et récapitule chaque source dans le message.
- **Prudence du rapprochement par NOM** : quand un créneau annonce des
  artistes, SEULS leurs noms peuvent servir de clé approchée ; le titre
  ne doit jamais suffire (« Initiation à la danse » attrapait la fiche
  d'une autre compagnie par le mot « danse »). Le mot approché doit faire
  six lettres au moins et ne pas figurer dans MOTS_GENERIQUES.

## 2026-07-23 : Raccordement PAR IDENTIFIANT du programme (Merlin v1.60)

- **Fichiers** : migration `add_source_refs_to_merlin_slots`,
  `app/Models/MerlinSlot.php`, `app/Services/MerlinService.php`,
  `app/Support/PlanningMatcher.php`, `Api/PlanningApiController`,
  `app/Support/Tables::colonne()`.
- **Quoi** : Merlin expose sur chaque créneau `source` + `source_ref`
  (`comte:{id activité}`, `guest:{uuid activité}`,
  `guestact:{uuid participation}:{empreinte}`, null = créé à la main) et
  sur chaque artiste `source` + `external_id` + `ref`. Correspondances
  côté fflv2 : `external_id` d'un artiste `guest` = `GuestProfile.external_id`
  (uuid de participation), `external_id` d'un artiste `comte` et suffixe de
  `comte:` = `FestorgaActivity.external_id`.
- **Ordre de résolution** (à respecter) : 1. `source_ref` du créneau,
  2. identifiant d'un artiste, 3. nom (dernier recours, fiches
  manuelles). Le nom seul cassait à chaque renommage côté source.
- **Reproduire** : ajouter les deux colonnes, les remplir à l'import
  derrière `Tables::colonne('merlin_slots', 'source_ref')` (la livraison
  doit tourner avant la migration), puis indexer les fiches locales par
  identifiant en plus du slug de nom.

## 2026-07-23 : RÈGLE - une livraison doit tenir SANS ses migrations

- **Fichiers** : `app/Support/Tables.php`,
  `tests/Feature/DeploiementSansMigrationTest.php`, et les gardes posées
  dans `HomeController`, `Api/InfosController`, `Api/PlanApiController`,
  `GoController`, `Api/TrackController`.
- **Quoi** : le déploiement dépose les fichiers (FTP), mais les
  migrations sont appliquées À LA MAIN ensuite. Entre les deux, le code
  neuf tourne sur l'ANCIENNE base. Toute nouveauté qui s'appuie sur une
  table neuve doit donc demander `Tables::existe('nom_table')` avant
  d'interroger la base, et prévoir un repli (contenu statique, liste
  vide, redirection). Jamais d'erreur 500 sur une page publique.
- **Reproduire** : copier `Tables` et le test ; y AJOUTER le nom de
  chaque nouvelle table livrée. Même vigilance pour une COLONNE ajoutée
  à une table existante : elle casse la requête de la même façon.

## 2026-07-23 : Infos pratiques partagées entre les sites

- **Fichiers** : `app/Models/SiteInfo.php`, migration
  `create_site_infos_table` (elle SÈME les cartes déjà écrites en dur dans
  le HTML : à adapter avant de la rejouer ailleurs),
  `Admin/SiteInfoController` + `resources/views/admin/infos.blade.php` +
  routes `admin/infos*` + entrée de menu, `Api/InfosController` (+ route
  GET /api/v1/infos), `resources/views/site/infos-cards.blade.php`,
  `HomeController::infosPratiques()`, et le repère `<!--INFOS-->` posé
  dans `Site_FFL_2026.html` et `Infos_FAQ_FFL_2026.html`.
- **Quoi** : les informations pratiques (dates, lieu, accès, parking,
  paiement, accessibilité...) quittent le HTML pour la base. fflv2 est la
  SOURCE ; les autres sites de l'écosystème les reprennent.
  Champs : `icon` (nom d'icône Material), `title`, `body` (les retours à
  la ligne sont conservés), `link_label`, `link_url`, `position`,
  `is_published`. L'adresse `billetterie` est un mot-clé : l'API la
  remplace par l'URL réelle du réglage `billetterie_url`, pour que le
  site qui reçoit n'ait pas à connaître notre configuration.
- **Rendu CÔTÉ SERVEUR** : `HomeController` remplace `<!--INFOS-->` par
  les cartes. Ne JAMAIS injecter ces contenus en JavaScript : ce sont des
  textes à référencer (« horaires », « accès », « parking »). La page
  d'accueil n'affiche que les 6 premières cartes
  (`INFOS_SUR_L_ACCUEIL`), la page dédiée les affiche toutes.
- **Reproduire sur un site CONSOMMATEUR** (recette maison, la même que
  pour Merlin ou Orgexpo) : appeler `GET https://<source>/api/v1/infos`
  DEPUIS LE SERVEUR (jamais depuis le navigateur du visiteur), garder la
  réponse dans une table de cache locale, rafraîchir par cron une fois
  par heure, et rendre les cartes côté serveur avec le même repère
  `<!--INFOS-->`. Le site continue ainsi d'afficher les infos si la
  source est injoignable. Aucune clé n'est nécessaire (tout est public),
  mais l'origine doit figurer dans la liste `CHAT_ALLOWED_ORIGINS` si un
  appel navigateur est un jour ajouté.

## 2026-07-22 : Plan interactif du site (API Plan de Merlin)

- **Fichiers** : `app/Services/MerlinPlanService.php`,
  `app/Models/MerlinPlan.php`, migration `create_merlin_plans_table`,
  `Api/PlanApiController` (+ route GET /api/v1/plan),
  `Admin/PlanController` + `resources/views/admin/plan.blade.php` +
  routes admin/plan* + menu latéral, `resources/site/Plan_FFL_2026.html`
  (bloc `#pli-*` : SVG en mètres, zoom/pan, recherche, légende, pop-up),
  `app/Support/PreviewToken.php` (ex-PlanningPreview, désormais à portée :
  'planning' | 'plan'), `HomeController` (injection window.PLAN_PREVIEW),
  cron + TasksController + ResyncController.
- **Quoi** : Merlin expose `GET /events/{uuid}/plan` (scopes `plan:public`
  ou `plan:read`). Le site importe et ne garde QUE le public : vignettes
  `public=true` et non annulées, fonds sauf `type=technique`, annotations
  `etiquette`/`bloc` seulement ; les champs internes (statut, notes,
  pilote, alerte) ne sont jamais recopiés. Les images (fonds,
  autocollants) sont téléchargées dans `public/img/plan/` car elles
  réclament la clé API. Dessin : tout en mètres, origine coin
  haut-gauche, `x_m/y_m` = coin avant rotation, rotation autour du
  `centre` ; attention à l'épaisseur des traits (1 = un mètre).
  Dévoilement : setting `plan_publish_at`, jeton HMAC quotidien de
  portée « plan » pour les connectés.
- **Complément (même jour)** : fiche complète au clic (titre, auteur,
  catégorie, description, image, liens site/Facebook/Instagram issus de
  La Comte ou d'Orgexpo via `source`+`ref`) ; module sous le plan
  (tri par source Animations/Exposants/Autres, légende cliquable par
  typologie, recherche, liste de résultats qui recentre la vue) ;
  RÈGLE : une vignette de source `comte`, `orgexpo` ou `stand-interne`
  SANS `numero` est écartée à l'import (emplacement non attribué) ; seuls
  les décors et dessins libres restent visibles sans numéro. Correctif responsive de la page
  (les media queries `.reperes` étaient déclarées avant la règle de
  base : à re-vérifier sur les sites copiés).
- **Puces (Merlin v1.12)** : chaque élément porte `puce {x_m,y_m}`
  (position ABSOLUE du repère qui affiche le `numero`, par défaut le
  centre mais déplaçable). Le site ne dessine une puce QUE pour les
  animations `source=comte` avec `numero` (leurs grands blocs masquaient
  le fond) ; exposants et stands internes gardent leur vignette. Goutte
  à TAILLE CONSTANTE À L'ÉCRAN : rayon en pixels converti en mètres via
  `mParPx = vue.w / largeurPx`, recalculé à chaque redessin ; remplissage
  `couleur_fond`, encre blanche ou foncée selon la luminance YIQ.
  Zoom AMORTI (retour d'usage) : facteur `min(2.2, max(0.9, (base.w /
  vue.w)^0.4))` : la puce grossit à la racine du zoom au lieu d'être
  strictement constante, sinon elle paraît minuscule une fois zoomé.
  Piège : rendre `#pli-wrap` visible AVANT le premier dessin, sinon
  `box.clientWidth` vaut 0 et l'échelle des puces est fausse.
  Les scènes affichent leur NOM en grand dans la vignette (deux lignes
  équilibrées si besoin), les autres vignettes leur numéro.
- **Scènes (Merlin v1.13)** : nouvelle `source: "scene"`, `ref` =
  identifiant de la scène du module Plannings (le MÊME que
  `merlin_slots.scene_id`). La fiche d'une scène liste donc ses créneaux
  depuis le cache du programme, sous condition de
  `PlanningMatcher::published()` (rien avant la diffusion du programme).
  Une scène n'a pas de numéro d'emplacement : elle n'entre pas dans la
  règle du numéro obligatoire et garde sa vignette (pas de puce).
- **Confort de lecture (retours d'usage)** : vue BORNÉE (`borne()` : pas
  de dézoom au-delà de la vue d'ensemble, pas de glissement hors du
  terrain) ; textes SORTIS du groupe pivoté (la forme tourne, le texte
  reste horizontal ; côtés échangés pour le calcul de taille quand la
  rotation approche 90°) ; fiche d'une scène = lien
  `/programme#scene-<ref>` ouvert dans un onglet, et la page programme
  gère cette ancre (bon jour, défilement, mise en évidence 4 s) ; le
  planning d'une scène s'affiche aussi en aperçu (membre connecté).
- **Jeu d'Yggdrasil (Merlin v1.16 et v1.25)** : deux nouvelles sources,
  `cle` (serrure du jeu des clés) et `personnage` (quête des
  personnages). Pastilles d'UN MÈTRE, `forme: "rond"`, couleur parmi six,
  champ `lie_a` = uuid de la vignette (stand) où le joueur doit se
  rendre. Le site les dessine comme les puces, à taille d'écran
  (`max(12 px * facteurZoom * mParPx, 0.5 m)`), avec un trou de serrure
  ou une silhouette au centre ; la fiche donne la catégorie du jeu et le
  nom du stand raccordé (résolu côté serveur via la table id -> nom des
  éléments du plan, jamais exposée telle quelle). Elles forment un
  quatrième bouton de tri (« Jeu d'Yggdrasil »).
- **Autocollants (Merlin v1.22)** : les décors collés arrivent avec un
  `ordre` NÉGATIF pour passer sous les vignettes. L'import trie donc les
  éléments par `ordre` (`sortBy('ordre')`) et le site dessine les
  `source=autocollant` comme de simples images, SANS classe `.pli-el` :
  pas de clic, pas de fiche, exclus de la recherche, de la légende et du
  compte « X emplacement(s) ».
- **Miroirs (Merlin v1.29)** : `miroir_x` / `miroir_y` (booléens, vrais
  seulement pour les autocollants) retournent l'IMAGE dans sa boîte, sans
  toucher à la position ni à la taille. Rendu : un groupe interne
  `translate(cx,cy) scale(±1,±1) translate(-cx,-cy)` autour du centre de
  la vignette, à l'INTÉRIEUR du groupe de rotation.
- **Formes** : Merlin dit `forme: "rond"`, le site normalise en `cercle`
  à l'import (et le dessin accepte les deux, par sécurité). Piège
  historique : une vignette ronde était dessinée carrée.
- **Annotations fléchées** : `cible_x_m` / `cible_y_m` (pointe de la
  flèche d'une étiquette) et `miroir` sont désormais repris ; le site
  trace le trait de l'étiquette vers sa cible et encadre en pointillé
  les annotations de `type: "bloc"`.
- **Performance (diagnostic réel du 26/08/2026)** : les décors collés
  arrivaient en pleine résolution, 28,7 Mo pour 57 images, sur une page
  qui les affiche à quelques dizaines de pixels. Trois règles à
  reproduire partout :
  1. `imageLocale()` télécharge EN FLUX (`Http::sink()`, jamais le corps
     en mémoire : un fond de plan pèse des dizaines de Mo), nomme le
     fichier `<prefixe>-<md5 tronqué>.<ext>` (donc cache navigateur d'un
     an possible, voir `public/img/plan/.htaccess`, à ne pas oublier dans
     le `.gitignore` du dossier), et `alleger()` le ramène en WebP à la
     taille d'affichage (12 px par mètre, 900 px max pour un décor,
     2800 px pour un fond). Les échecs sont collectés
     (`imagesEchouees()`) et affichés dans le back-office.
  1bis. QUOTA : l'API Merlin accepte 60 requêtes par minute et par clé.
     Un plan de 60 décors demandait autant d'images : les dernières
     (dont le fond de plan) revenaient en 429 et manquaient sur le site.
     Règle : mémoriser la correspondance `adresse Merlin => fichier
     local` (réglage `merlin_plan_images`, en JSON) et ne rien
     redemander tant que le fichier est là ; espacer les nouveaux
     appels (50 par minute) ; réessayer une fois après un 429. Un
     bouton « Importer, images comprises » force la reprise complète.
  2. L'adresse `image_url` de Merlin PORTE DÉJÀ sa requête
     (`?finesse=vignette` depuis Merlin v1.46) : ne jamais passer un
     tableau de query à `Http::get()`, cela l'écraserait ; concaténer.
  3. Côté page : le SVG n'est PAS reconstruit pendant un geste. Les
     puces et pastilles (seuls dessins à taille d'écran) vivent dans un
     calque `#pli-marks` ; un glissement ou un zoom ne change que le
     `viewBox` et ce calque (`cadrer()`), le dessin complet
     (`dessine()`) n'est refait qu'au relâchement, ou 140 ms après la
     dernière molette.
- **Recherche des emplacements** : la liste de résultats ne doit PAS
  être tronquée (un `slice(0, 60)` faisait croire qu'un exposant n'était
  pas sur le plan) et doit être rangée par ordre alphabétique du nom
  affiché (`localeCompare` en 'fr'), pas dans l'ordre de pose. Rendu par
  `DocumentFragment` : 219 résultats coûtent moins de 6 ms par frappe.
- **Fraîcheur** : `import()` interroge d'abord `/plan/version` et ne
  recharge le plan entier que si l'`empreinte` a changé (60 req/min) ;
  `import(force: true)` pour le bouton admin.
- **Reproduire** : copier service + modèle + migration + contrôleurs +
  bloc de page, créer une clé Merlin avec le scope `plan:public`.

## 2026-07-22 : Page publique « Nos partenaires » (/partenaires)

- **Fichiers** : `resources/site/Partenaires_FFL_2026.html` (nouvelle page),
  migration `add_page_fields_to_partners` (category, description,
  is_featured), `app/Models/Partner.php` (constante CATEGORIES +
  `categoryLabel()`), `Api/SiteController` (partners enrichis :
  category, category_label, description, featured),
  `Admin/PartnerController` + `admin/partners/index.blade.php` (champs
  catégorie / présentation / vedette), `HomeController::PAGES` (slug
  `partenaires`), `SeoController` (sitemap), `config/photos.php`
  (bannière), lien « Nos partenaires » dans les menus Découvrir de
  toutes les pages (après « Le programme »).
- **Quoi** : page alimentée par `/api/v1/site` (aucune donnée en dur) :
  vedettes en grand, puis groupes par catégorie dans l'ordre de position,
  carte cliquable si le partenaire a une URL, repli sur le nom en Cinzel
  si le logo manque ou ne charge pas, état vide soigné, section
  « Devenir partenaire ».
- **Reproduire** : copier la page HTML (adapter textes), la migration,
  les champs du modèle/API/admin, puis enregistrer le slug, le sitemap,
  l'emplacement photo et les liens de menu.

## 2026-07-22 : Sélecteur de médiathèque mutualisé + module d'image des partenaires

- **Fichiers** : `resources/views/admin/partials/media-picker.blade.php`
  (NOUVEAU composant : modale dossiers + vignettes, CSS et JS délégué),
  `resources/views/admin/planning.blade.php` (utilise l'include, code
  dupliqué retiré), `resources/views/admin/partners/index.blade.php`
  (refonte en fiches + zone de dépôt), `Admin/PartnerController`
  (logo_url depuis la médiathèque, remove_logo, suppression de fichier
  limitée à /img/partners/).
- **Quoi** : composant réutilisable pour choisir une image de la
  médiathèque dans n'importe quel formulaire admin (conteneur
  `.pikfield` + `<img class="pikprev">` + input + boutons `.piklib` /
  `.pikclear` ; le choix déclenche l'événement `change`). Côté
  partenaires : glisser-déposer, aperçu immédiat (FileReader), choix
  médiathèque ou fichier local, retrait ; une image de médiathèque est
  référencée telle quelle (jamais copiée ni supprimée du disque).
- **Reproduire** : copier le partial, l'inclure UNE fois par page, puis
  entourer chaque champ image d'un `.pikfield`.

## 2026-07-22 : Photographes : statut d'après le forfait + albums sans compte

- **Fichiers** : `app/Services/PhotographesService.php` (`estOfficiel()`,
  external_id fabriqué pour les albums sans compte), migration
  `add_sans_compte_to_photographe_profiles`, `app/Models/PhotographeProfile.php`,
  `Api/PhotographesController` (annuaire : `where('sans_compte', false)` ;
  albums : URL directe pour les sans-compte),
  `resources/views/admin/photographes.blade.php` (forfait affiché, alerte
  0 photo, badge album sans compte).
- **Quoi** : (1) le statut officiel/accrédité se déduit du LIBELLÉ du
  forfait du dossier de l'édition (« accrédité » l'emporte), car le champ
  `type`/`droits_photos` de l'API peut le contredire (données source
  incohérentes) ; (2) les entrées `id: null` (albums « sans compte » =
  CreditAlbum côté source) étaient sautées à l'import : on leur fabrique
  un external_id stable `credit-<md5 du crédit>` et on les marque
  `sans_compte` pour les exclure de l'annuaire mais les garder dans les
  albums (leur `espace_public_url` pointe déjà sur l'album).
- **Reproduire** : copier `estOfficiel()`, la migration, les deux filtres
  du contrôleur API et les libellés admin.

## 2026-07-22 : Module « Liens publicitaires » (/go/{slug}) + campagnes dans l'API stats

- **Fichiers** : migration `create_promo_links_tables` (promo_links,
  promo_clicks), `app/Models/{PromoLink,PromoClick}.php`,
  `app/Support/Audience.php` (hachage anonyme quotidien + device + bot,
  MUTUALISÉ avec TrackController), `app/Http/Controllers/GoController.php`
  (+ route publique GET /go/{slug}),
  `app/Http/Controllers/Admin/PromoLinkController.php` +
  `resources/views/admin/promo/{index,show}.blade.php` + routes
  admin/liens-pub* + menu latéral, `Api/StatsApiController` (bloc
  `campagnes`, AJOUT COMPATIBLE au contrat v1 : contrat reste 1, les
  consommateurs ignorent les champs inconnus).
- **Quoi** : liens courts à donner aux partenaires. Admin : création
  (libellé + slug auto modifiable + destination accueil|billetterie),
  activation, suppression ; stats par lien : clics, visiteurs uniques,
  aujourd'hui/7 j, par jour (14 j), appareils, référents, langues, et
  RENTABILITÉ pour les liens accueil : pages vues + clics billetterie
  des visiteurs venus par le lien (jointure visitor_hash + même jour,
  possible car le hachage anonyme est le même que l'audience).
  Journalisation CÔTÉ SERVEUR (insensible aux bloqueurs de pub) et
  conforme RGPD : jamais d'IP, hachage quotidien non réversible, robots
  exclus, Do Not Track respecté. Slug non modifiable après création
  (liens imprimés) : désactiver et recréer.
- **Reproduire** : copier Audience + modèles + migration + GoController +
  route + contrôleur/vues admin + bloc campagnes de l'API stats.

## 2026-07-21 : Programme : bulles de personnes + sur-pop-up + présentateurs

- **Fichiers** : `Api/PlanningApiController` (champ `people` par créneau :
  {name, presenter, category, photo, bio} via `PlanningMatcher::guestByName()`
  exact), `app/Models/GuestProfile.php` (`isPresentateur()`),
  `resources/site/Programme_FFL_2026.html` (bulles cliquables, ligne
  « 🎤 Présenté par… », sur-pop-up `#prg-person` z-index au-dessus de la
  pop-up créneau, Échap ferme la sur-pop-up d'abord).
- **Quoi** : dans la pop-up d'un créneau, chaque personne devient une
  bulle ; si sa fiche Yggdra-Guest existe (et visible), la bulle est
  cliquable (▸) et ouvre une sur-pop-up photo + catégorie + bio. Les
  présentateurs (catégorie contenant présentateur/animateur) sortent des
  bulles artistes et s'affichent « Présenté par… ». La pop-up du créneau
  se remplit aussi depuis la fiche Guest (manuel > La Comte > Guest).
- **Reproduire** : copier le champ `people`, `guestByName()`,
  `isPresentateur()` et le bloc JS/CSS de la page programme.

## 2026-07-21 : Passages sur scène dans les fiches + connecteur Yggdra-Guest

- **Fichiers** : `app/Support/PlanningMatcher.php` (matching mutualisé
  créneau <-> activité + `passagesParActivite()` gardé par la date de
  diffusion), `Api/ActivitiesController` (champ `passages` par activité),
  `Api/PlanningApiController` (refactor sur le matcher),
  `public/site/site-dynamic.js` (`passagesBlock()` dans les pop-ups
  troupe et animation + styles `.fga-passages*`) ; connecteur Guest :
  `app/Services/GuestsService.php`, `app/Models/GuestProfile.php`,
  migration `create_guest_profiles_table`, `Admin/GuestsController` +
  `admin/guests.blade.php` + routes + menu latéral, `config/services.php`
  (`services.guests`), `.env(.example)` (`GUEST_API_KEY`,
  `GUEST_BASE_URL`), cron + TasksController, `public/img/guests/.gitignore`.
- **Quoi** : (1) les fiches troupes/animations annoncent « quoi et
  quand » (passages Merlin, jour + horaires + scène) UNIQUEMENT quand le
  programme est diffusé. (2) Préparation invités : l'API Yggdra-Guest
  (header `X-API-Key` fge_xxx, scopes read:events, read:participations,
  read:persons, read:media) alimente un cache `guest_profiles` (identité
  publique, bio officielle, catégorie de participation dont
  « Présentateur », photo protégée téléchargée dans public/img/guests/
  pour que la clé ne transite jamais par le navigateur, photos non
  publiables ignorées). Visibilité par fiche (survit aux ré-imports).
  Le site public n'affiche rien pour l'instant.
- **Reproduire** : copier PlanningMatcher + le champ passages + le bloc
  JS ; copier le connecteur Guest tel quel (une clé API par site,
  générée dans Yggdra-Guest, Administration → API & Webhooks).

## 2026-07-21 : Programme : tableaux de scènes composables (glisser-déposer)

- **Fichiers** : `app/Support/PlanningGroups.php` (normalisation),
  `app/Http/Controllers/Admin/PlanningController.php` (edit/update
  `groups_json`), `app/Http/Controllers/Api/PlanningApiController.php`
  (champ `groups` dans /api/v1/planning),
  `resources/views/admin/planning.blade.php` (UI glisser-déposer),
  `resources/site/Programme_FFL_2026.html` (rendu multi-tableaux).
- **Quoi** : setting `merlin_scene_groups` = [{name, bg, scenes:[ids]}].
  Back-office : pastilles de scènes déplaçables entre « tableaux »
  (fusion côte à côte ou scène seule), ordre par flèches, titre + fond
  (médiathèque) par tableau, sérialisé en JSON au submit. Normalisation
  côté serveur : scènes disparues retirées, nouvelles scènes ajoutées
  seules en fin (reprend l'ancien merlin_scene_bg). Site : tous les
  tableaux affichés à la suite (scroll-margin-top), scènes d'un même
  tableau en colonnes partageant l'axe horaire (en-têtes de colonnes),
  raccourcis = boutons qui scrollIntoView vers le tableau ; un tableau
  sans créneau le jour affiché est masqué, une scène vide d'un tableau
  fusionné disparaît de ses colonnes ce jour-là.
- **Reproduire** : copier PlanningGroups + les blocs admin/API/page.
  Remplace « Image de fond par scène » (le fond est désormais par
  tableau).

## 2026-07-21 : Programme : enrichissement auto La Comte + tuiles adaptatives

- **Fichiers** : `app/Http/Controllers/Api/PlanningApiController.php`
  (`activiteCorrespondante()`), `resources/site/Programme_FFL_2026.html`
  (classes `.prg-sm`/`.prg-xs`), `resources/views/admin/planning.blade.php`
  (aide mise à jour).
- **Quoi** : la pop-up d'un créneau se remplit automatiquement avec la
  fiche de l'activité La Comte correspondante (description + image, avec
  la chaîne de repli image/photo/logo de troupe) quand aucune fiche
  manuelle n'existe. Correspondance : égalité de slug (titre/artistes vs
  nom d'activité/troupe), sinon mot significatif commun s'il ne désigne
  qu'UNE activité. La fiche manuelle (merlin_extras) garde la priorité.
  Tuiles : classes prg-sm (h<74px : heure + titre) et prg-xs (h<46px :
  titre seul) pour que rien ne soit coupé sur les créneaux courts.
- **Reproduire** : copier `activiteCorrespondante()` et le bloc slots du
  contrôleur (adapter au connecteur activités du site cible), + le CSS
  et le calcul de classe dans board() de la page programme.

## 2026-07-21 : Page « Le programme » (/programme) + connecteur Merlin

- **Fichiers** : `app/Services/MerlinService.php`,
  `app/Models/{MerlinSlot,MerlinExtra}.php`, migration
  `2026_07_21_000100_create_merlin_planning_tables.php`,
  `app/Http/Controllers/Api/PlanningApiController.php` (+ route GET
  /api/v1/planning), `app/Support/PlanningPreview.php`,
  `app/Http/Controllers/Admin/PlanningController.php` +
  `resources/views/admin/planning.blade.php` (+ routes admin/planning*,
  menu latéral), `resources/site/Programme_FFL_2026.html`,
  `HomeController` (slug `programme` + injection du jeton de
  prévisualisation), sitemap, `config/photos.php` (bannière),
  `config/services.php` (`services.merlin`), `.env(.example)`
  (`MERLIN_API_KEY`, `MERLIN_BASE_URL`), éditeur .env, cron +
  `TasksController` (import ajouté), lien « Le programme » dans les menus
  Découvrir de toutes les pages (après « Le plan »).
- **Quoi** : page publique du programme des scènes, alimentée par l'API
  Merlin (`GET {base}/events/{uuid}/programme`, header `X-Api-Key`, scope
  `programme:read`), pattern cache-first (import bouton + cron horaire,
  purge des créneaux disparus). Page interactive : onglets par jour,
  sélecteur par scène, grille horaire (tuiles positionnées à la minute),
  pop-up par créneau (résumé + image saisis dans le back-office, fiche
  `merlin_extras` qui survit aux ré-imports), image de fond par scène
  (réglage back-office). **Date de diffusion** (`planning_publish_at`) :
  avant la date, le public voit « Bientôt disponible » (l'API ne renvoie
  RIEN), les membres connectés voient le programme avec un bandeau rouge
  grâce à un jeton HMAC quotidien (`PlanningPreview::token()`, signé
  APP_KEY) injecté dans la page ; champ vide = jamais diffusé.
- **Reproduire** : copier service + modèles + migration + contrôleurs +
  vue admin + page site (adapter menus/couleurs), déclarer la clé API
  côté Merlin (une clé par site, scope programme:read), config/env,
  ajouter l'import au cron. L'API Merlin est commune à tous les sites.

## 2026-07-21 : Activités : repli d'image sur le logo de la troupe

- **Fichier** : `app/Http/Controllers/Api/ActivitiesController.php` (+ test
  `tests/Feature/FestorgaTest.php`)
- **Quoi** : dans `/api/v1/activities`, le champ `image` applique désormais
  une chaîne de repli : image de l'activité, sinon **1re photo** de
  l'activité, sinon **logo de la troupe** (`troupe_data.logo`), sinon null
  (le site affiche alors sa vignette de remplacement). Fini les vignettes
  par défaut quand la troupe a un logo.
- **Reproduire** : dans `index()`, remplacer `'image' => $a->image` par
  `'image' => $a->image ?: (($a->photos[0] ?? null) ?: ($a->troupe_data['logo'] ?? null))`.

## 2026-07-17 : API de statistiques pour l'app centrale (CONTRAT STANDARD)

- **Fichiers** : `app/Http/Controllers/Api/StatsApiController.php`,
  `routes/api.php` (GET /api/v1/stats, throttle 30/min),
  `config/services.php` (`services.stats.key`), `.env(.example)`
  (`STATS_API_KEY`), éditeur .env (champ « Clé de l'API statistiques »).
- **Quoi** : endpoint lecture seule pour agréger les stats de tous les
  sites du socle dans une app centrale. **À répliquer à l'identique**
  (même URL, même en-tête, même format) sur chaque site (Indoor…).
- **Contrat v1** :
  - `GET /api/v1/stats?days=30` (7|30|90|365) ; en-tête `X-Stats-Key`
    = STATS_API_KEY du .env ; clé vide → 503 ; clé fausse → 401.
  - Réponse JSON : `contrat` (1), `site{nom,url}`, `genere_le`,
    `periode_jours`, `audience{vues_periode, visiteurs_periode,
    vues_aujourdhui, vues_total, par_jour[{date,vues}] (jours vides
    inclus), top_pages[{path,vues}], top_referents[{hote,vues}],
    appareils{}, evenements[{label,total}]}`, `chatbot{conversations,
    messages, questions_sans_reponse, faq_ouvertures, reponses_servies,
    leads{nouveau,transmis,traite,total}}`, `contenus{...compteurs}`,
    `ia{cout_mois_micro_euros, cout_total_micro_euros, tokens_entree,
    tokens_sortie}`.
  - Aucune donnée personnelle (l'audience est anonyme par conception).
  - Si le format évolue : incrémenter `contrat` et documenter ici.
- **Reproduire** : copier le contrôleur (adapter les modèles absents du
  site cible en renvoyant 0), la route, la config, la clé .env + champ
  de l'éditeur .env. Générer une STATS_API_KEY différente par site.

## 2026-07-16 : Annuaire exposants : ne plus exiger de numéro de stand

- **Fichier** : `app/Http/Controllers/Api/ExposantsController.php` (+ test
  `tests/Feature/OrgexpoTest.php`)
- **Quoi** : l'API `/api/v1/exposants` filtrait sur `emplacement_numero`
  non vide (« présence confirmée ») : pour une édition à venir, aucun stand
  n'est attribué, donc l'annuaire public restait VIDE malgré un import OK.
  Le filtre est supprimé : le cache ne contient déjà que des exposants
  validés, on les affiche tous (l'`emplacement` vaut null sans stand).
- **Reproduire** : dans `ExposantsController::index()`, retirer
  `->whereNotNull('emplacement_numero')->where('emplacement_numero','!=','')`
  (garder event_id + tri par nom). Report du correctif Indoor
  « Fix createurs vides : ne plus exiger de numero de stand ».

## 2026-07-15 : Bouton « S'inscrire en tant que photographe » (page Photographes)

- **Fichiers** : `resources/site/Photographes_FFL_2026.html` (bouton dans le
  héros, `id="pho-inscription"`, masqué par défaut),
  `public/site/site-dynamic.js` (`wirePhotographeSignup()` appelé depuis
  `apply()`).
- **Quoi** : bouton doré « S'inscrire aux événements en tant que
  photographe » dans le héros de la page Photographes. Son URL vient du
  lien Pro `slug=photographes` (back-office → Liens, servi par
  `/api/v1/site`) : modifiable sans toucher au code, bouton masqué si
  l'URL est vide.
- **Reproduire** : ajouter le `<a id="pho-inscription" style="display:none">`
  dans le héros, puis la fonction `wirePhotographeSignup(cfg.links)` qui
  cherche le lien `slug === "photographes"` avec URL et affiche le bouton.

## 2026-07-14 : Nouvelle page « Le plan » (/plan)

- **Fichiers** : `resources/site/Plan_FFL_2026.html` (nouvelle page),
  `app/Http/Controllers/HomeController.php` (slug `plan`),
  `app/Http/Controllers/SeoController.php` (sitemap), `config/photos.php`
  (2 emplacements : bannière + image du plan), menus « Découvrir » de
  toutes les pages (`<a href="/plan">Le plan</a>` après « Les animations »,
  desktop + mobile + footers qui listaient les animations).
- **Quoi** : page plan du festival : image encadrée cliquable
  (`/img/plan-ffl-2026.jpg`), boutons « pleine résolution » et
  « télécharger », placeholder rayé si l'image manque (onerror ->
  `.plan-wrap.noplan`), grille de 6 repères (lieux clés), clin d'œil
  chasse aux clés, CTA final. Inspirée de la page Plan d'Indoorv2.
- **Reproduire** : copier `Plan_FFL_2026.html` en adaptant contenus/lieux ;
  enregistrer le slug dans HomeController::PAGES, le sitemap, les
  emplacements photos, et insérer le lien dans les menus de chaque page
  (attention au lien porteur de `class="active"` sur sa propre page).

## 2026-07-14 : Anti-cache automatique des CSS/JS du site

- **Fichier** : `app/Services/SeoRenderer.php`
- **Quoi** : les URLs `/site/header.css`, `/site/site-dynamic.js` et
  `/widget/chat-widget.js` reçoivent automatiquement `?v={version}` (version
  lue du CHANGELOG) lors du rendu serveur des pages. Chaque release invalide
  donc les caches navigateur/serveur : les visiteurs voient les mises à jour
  sans vider leur cache.
- **Reproduire** : dans `SeoRenderer::render()`, après les optimisations,
  `str_replace` des trois URLs par leur variante `?v=`.

## 2026-07-14 : Pop-up exposant : image carrée cadrée en haut

- **Fichier** : `public/site/site-dynamic.js`
- **Quoi** : dans la pop-up d'un exposant (page Créateurs), l'image passe d'un
  bandeau large (`max-height:340px`, recadrage centré) à un **carré centré**
  cadré **en haut** (`aspect-ratio:1/1`, `object-position:center top`,
  largeur `min(340px,100%)`, centré `margin:auto`). Les visages et stands ne
  sont plus coupés.
- **Reproduire** : dans `openExposantModal()`, ajouter la classe `fga-img-sq`
  à l'image (`imgEl(e.image, e.nom, "fga-modal-img fga-img-sq")`) et ajouter
  la règle CSS `.fga-img-sq{aspect-ratio:1/1;max-height:none;width:min(340px,100%);margin:16px auto;display:block;object-position:center top}`
  dans `injectFestorgaStyle()`. La pop-up des animations garde l'ancien format.
