# Architecture des donnees — site Biome Constructions

Ce document mappe chaque section de la maquette a sa source de donnees et dit
si elle est editable dans l'admin. Il fait reference pour dev-backend et
dev-frontend. Source de verite visuelle : l'export Claude Design.

## 1. Sources de donnees

Trois natures de donnees, a ne pas melanger :

- **CRM (lecture seule)** : les biens a vendre et leurs photos. Non editables
  dans l'admin. Acces via `CrmClient` uniquement.
- **MySQL (editable dans l'admin)** : contenus propres au site.
- **Formulaires (soumissions entrantes)** : demandes/leads stockes en MySQL,
  consultables dans l'admin, non "edités" comme du contenu.

## 2. Inventaire section par section

| Section (maquette)        | Source        | Editable admin ? | Ressource admin        |
|---------------------------|---------------|------------------|------------------------|
| Accueil (hero, blocs)     | MySQL (`contenus_pages`, `page = 'accueil'`) + CRM (biens) + MySQL (`partenaires`) | Oui (textes/images, liens fixes dans le code) | `ContenuPageResource` (menu "Contenu du site" > "Contenu de page") pour les textes/images ; `PartenaireResource` (menu "Contenu du site" > "Partenaires") pour les 4 cartes partenaires |
| Acheter (recherche/carte) | CRM (lecture) | Non              | — (`App\Http\Controllers\AcheterController@index`, vue `pages/acheter.blade.php`) |
| Bien (detail + galerie)   | CRM (lecture) | Non              | — (`App\Http\Controllers\BienController@show`, vue `pages/bien.blade.php`) |
| Actualites                | MySQL (`actualites`) | Oui       | `ActualiteResource` (menu "Contenu du site") |
| Recrutement / Offres emploi | MySQL (`offres_emploi`) | Oui   | `OffreEmploiResource` (menu "Contenu du site") |
| Partenaires & sous-traitants | MySQL (`partenaires`) | Oui   | `PartenaireResource` (menu "Contenu du site" > "Partenaires") |
| Qui-sommes-nous           | MySQL (`contenus_pages`, `page = 'qui-sommes-nous'`) + MySQL (`realisations`) | Oui | `ContenuPageResource` pour les textes/images (version complete, distincte de la version courte de l'Accueil) ; `RealisationResource` (menu "Contenu du site") pour le carrousel de realisations |
| Investir                  | MySQL (`contenus_pages`, `page = 'investir'`) + CRM (biens d'investissement) | Oui (textes/images) / Non (biens) | `ContenuPageResource` pour les textes/images ; section "Biens d'investissement" 100% CRM, aucune ressource admin (`CrmClientInterface::biensInvestissement()`) |
| Vendre mon terrain (form) | Formulaire    | Consultation     | `LeadTerrainResource`  |
| Contact (form)            | Formulaire    | Consultation     | `MessageContactResource` (branche maison/question) + `LeadTerrainResource` (branche terrain) |
| Planifier une visite (sur Bien) | Formulaire | Consultation   | `DemandeVisiteResource` |
| Demander infos / brochure / prix / rappel (sur Bien) | Formulaire | Consultation | `DemandeInfoBienResource` |
| Rappel generique (Contact / Acheter / Partenaires) | Formulaire | Consultation | `DemandeInfoBienResource` (`type=rappel`, sans bien) |
| Candidature offre d'emploi / spontanee | Formulaire | Consultation | `CandidatureResource` |
| Devenir partenaire / sous-traitant | Formulaire | Consultation | `CandidaturePartenaireResource` |

Note (Investir) : le bandeau "Recevez nos opportunites d'investissement" en
bas de `/investir` (champ e-mail + bouton) est **purement visuel**, comme le
bloc newsletter de la page Acheter — aucun endpoint d'inscription e-mail
n'existe dans ce lot, le bouton est affiche desactive
(`resources/views/pages/investir.blade.php`). A signaler si une vraie
inscription (avec sa propre table/lead) est souhaitee dans un lot ulterieur.

### 2 bis. Decisions client du 2026-07-03

Arbitrages actes avec Laurent (client) le 2026-07-03, qui tranchent les points
qui restaient ouverts sur ce lot et les lots voisins :

- **Accueil : textes et images editables, liens fixes dans le code.** Le hero
  et les autres blocs de l'Accueil (cartes parcours, "Qui sommes-nous" court,
  valeurs, en-tete Partenaires) sont geres via `ContenuPageResource`. Les URL
  des boutons (ex. "Voir nos biens disponibles") restent codees en dur dans
  `resources/views/pages/accueil.blade.php` : seul le texte affiche sur le
  bouton est editable, jamais sa destination. Voir
  `App\Filament\Resources\ContenusPages\Config\SchemaContenuPage`, qui est la
  source unique du schema de champs par page (et des valeurs par defaut
  utilisees par le seeder et par le controleur en repli).
- **Deux contenus "Qui sommes-nous" distincts.** La version courte affichee
  sur l'Accueil (`contenus_pages.contenus->qui_sommes_nous_court`) est geree
  independamment de la version complete de la page dediee `/qui-sommes-nous`
  (lot suivant, pas encore de cle `qui-sommes-nous` dans `SchemaContenuPage`
  a la date de cette note).
- **Partenaires de l'Accueil : selection manuelle.** Les 4 cartes affichees
  dans le bloc "Partenaires" de l'Accueil viennent du CRUD `PartenaireResource`
  (table `partenaires`), filtrees sur `mis_en_avant = true` et triees par le
  champ `ordre` (`Partenaire::scopeMisEnAvant()`), plafonnees a 4 par
  `AccueilController::index()`. Pas de lien automatique avec la page
  `/partenaires` (qui affichera probablement l'ensemble des partenaires,
  lot a confirmer).
- **Page Investir : 100% CRM, y compris loyer/rendement.** Aucune donnee
  d'investissement (loyer estime, rendement...) ne sera saisie dans l'admin :
  elle proviendra du CRM au meme titre que le reste des donnees d'un bien.
  Modelise dans le contrat `CrmClient` (section 3) : methode
  `biensInvestissement()`, champ `investissement` sur les biens fixtures
  concernes (`app/Services/Crm/fixtures/biens.php`).
- **Leads "guide" conserves, PDF fourni via l'admin.** Le mecanisme de
  formulaire "recevoir le guide" (a preciser lot par lot) reste un lead
  MySQL standard ; le PDF envoye sera uploade depuis l'admin (pas de
  generation dynamique cote site).
- **Galerie de realisations editable.** A la difference des biens a vendre
  (CRM strict), la galerie de chantiers/realisations termines sera geree en
  MySQL/admin (CRUD a livrer, pas encore implemente a la date de cette note).
- **PDF prix/plans fournis par le CRM.** Confirme : ces documents ne sont pas
  generes ni uploades cote site, ils viendront du CRM (voir section 3,
  `bien.pdf-prix` reste en stub HTTP 501 tant que ce point n'est pas
  implemente techniquement cote CRM).
- **Retention des leads : 24 mois.** Valeur desormais actee (plus seulement
  une valeur par defaut en attente de validation) — voir `docs/rgpd.md` et
  la variable `RETENTION_LEADS_MOIS`.
- **Pas de case a cocher consentement supplementaire** sur les formulaires
  (au-dela des mentions legales/finalite deja affichees).
- **Mobile : chantier a part, apres le developpement desktop.** L'adaptation
  mobile n'est pas traitee lot par lot en meme temps que le desktop ; elle
  fera l'objet d'une passe dediee une fois le site desktop termine.
- **Transmission au CRM de 3 types de leads.** Les leads "planifier une
  visite", "demande d'infos sur un bien" et "contact — branche maison" seront
  transmis au CRM (mecanisme a definir, lot dedie) ; tous les autres
  formulaires (terrain, candidature, partenaire, contact — branche question)
  restent strictement locaux (MySQL du site uniquement).

Ces decisions sont actees mais plusieurs impliquent encore un lot de
developpement dedie (Investir, galerie realisations, transmission CRM des
leads) : ce document sera mis a jour au fur et a mesure de leur livraison.

### 2 bis (suite). Transmission des leads vers le CRM

**Statut actuel : plomberie de transmission en place, implementation bouchon
active (aucun envoi reel).** Ce lot met en oeuvre la decision client du
2026-07-03 (section 2 bis) : certains leads acheteurs, en plus de rester
stockes et consultables localement (aucun changement de ce cote), sont
egalement transmis au CRM "Portail vendeur". Le contrat de transmission
(endpoint, authentification, format, accuse de reception) n'est PAS ENCORE
CONVENU avec l'equipe CRM : voir "Ce qui est attendu de l'equipe CRM"
ci-dessous. Le branchement HTTP reel est prevu au lot 12.

**Perimetre exact (3 tables, avec filtre pour l'une d'elles).**

| Table                 | Modele            | Concerne par la transmission |
|------------------------|--------------------|-------------------------------|
| `demandes_visite`      | `DemandeVisite`    | Tous les enregistrements |
| `demandes_info_bien`   | `DemandeInfoBien`  | Tous les enregistrements (tous types : infos, brochure, prix, rappel) |
| `messages_contact`     | `MessageContact`   | Uniquement `type_besoin = 'maison'` (la branche `question` reste strictement locale) |

Restent strictement locaux, jamais transmis au CRM : `leads_terrain`,
`candidatures`, `candidatures_partenaires`, et la branche `question` de
`messages_contact`.

**Mecanisme.**

- **Colonnes de suivi** (migration
  `2026_07_03_130000_ajouter_colonnes_transmission_crm_aux_leads.php`) sur
  les 3 tables : `transmis_crm_le` (timestamp nullable, rempli des que le CRM
  a accepte le lead) et `tentatives_transmission_crm` (entier non signe,
  incremente a chaque echec).
- **`App\Services\Crm\TransmetteurLeadCrmInterface`** : contrat de
  transmission sortante (methode `transmettre(string $type, array $payload):
  bool`), distinct de `CrmClientInterface` (lecture seule des biens). Bindee
  en singleton dans `AppServiceProvider`.
- **`App\Services\Crm\TransmetteurLeadCrmJournal`** : implementation bouchon
  active tant que le contrat n'est pas convenu. Ecrit une ligne de log
  structuree SANS donnee personnelle (type de lead, id local, horodatage
  uniquement) et retourne systematiquement `false` — donc aucun lead n'est
  jamais marque `transmis_crm_le` a tort par ce bouchon.
- **`App\Services\Crm\PayloadLeadCrm`** : construit le payload par type de
  lead a partir du modele Eloquent (voir structure ci-dessous).
- **Commande `app:transmettre-leads-crm`**
  (`App\Console\Commands\TransmettreLeadsCrmCommand`) : parcourt les 3
  tables, selectionne les enregistrements avec `transmis_crm_le` NULL et
  `tentatives_transmission_crm < 5` (scope `scopeATransmettre()` sur chaque
  modele ; filtre additionnel `type_besoin = 'maison'` applique par la
  commande pour `MessageContact`), appelle le transmetteur pour chacun,
  marque `transmis_crm_le = now()` si accepte, incremente
  `tentatives_transmission_crm` sinon. Option `--dry-run` : affiche les
  comptes par table sans rien transmettre. Sortie console sans donnee
  personnelle (compteurs uniquement).
- **Retentatives plafonnees a 5** : au-dela, un lead en echec repete n'est
  plus retente automatiquement (reste consultable et non marque transmis en
  admin ; traitement manuel a prevoir le jour ou le contrat CRM est fixe).
- **Planification** : `Schedule::command('app:transmettre-leads-crm')
  ->everyFifteenMinutes()` dans `routes/console.php`, declenchee par le meme
  cron unique OVH que le reste du scheduler (voir docs/deploiement-ovh.md).
  L'envoi n'est **jamais** effectue dans la requete du visiteur : la
  transmission est entierement asynchrone via cette tache planifiee, ce qui
  respecte la contrainte "pas de demon / jobs via cron" du mutualise OVH.
- **Visibilite admin** : les ressources Filament `DemandeVisiteResource`,
  `DemandeInfoBienResource` et `MessageContactResource` affichent une colonne
  et une entree "Transmis CRM" (badge) : "Oui le JJ/MM/AAAA HH:mm" (succes),
  "En attente" (pas encore transmis), ou "—" pour les enregistrements non
  concernes (`MessageContact` type `question`). Pas d'action manuelle de
  transmission pour l'instant (viendra avec le contrat reel).

**Payloads par type (voir PHPDoc de `PayloadLeadCrm` pour le detail exact).**
Socle commun a tous les payloads : `type` (`visite` | `info_bien` |
`contact_maison`), `id_local` (id MySQL du site, sert uniquement a correler
un accuse de reception, jamais utilise comme identifiant cote CRM),
`bien_id` (identifiant CRM du bien, `null` pour un rappel generique sans
bien), `date_soumission` (ISO 8601), et une cle `donnees` avec les champs
propres au formulaire :

- **`visite`** (`PayloadLeadCrm::pourDemandeVisite()`) — `donnees` : `nom`,
  `telephone`, `email`, `date_souhaitee` (`Y-m-d`), `message`.
- **`info_bien`** (`PayloadLeadCrm::pourDemandeInfoBien()`) — `donnees` :
  `type_demande` (`infos`|`brochure`|`prix`|`rappel`), `nom`, `prenom`,
  `email`, `telephone`, `souhaits` (tableau ou `null`), `message`.
- **`contact_maison`** (`PayloadLeadCrm::pourMessageContact()`) — `donnees` :
  `nom`, `email`, `telephone`, `budget`, `localisation`, `message`. `bien_id`
  vaut toujours `null` pour ce type (jamais rattache a un bien precis).

Cette structure est une PROPOSITION de base de discussion, pas encore un
contrat fixe : elle est amenee a evoluer une fois convenue avec l'equipe CRM,
sans impact sur `TransmetteurLeadCrmInterface` (qui accepte un simple
tableau).

**Ce qui est attendu de l'equipe CRM (a convenir, lot 12) :**
- Endpoint et methode d'acces (API HTTP ? flux fichier ? autre) pour recevoir
  ces 3 types de leads.
- Authentification (cle API, jeton, IP autorisee...). `config('services.crm.
  url')` et `config('services.crm.key')` existent deja (voir
  `config/services.php`, variables `CRM_API_URL` / `CRM_API_KEY`), pas encore
  utilisees par le bouchon actuel.
- Format d'accuse de reception attendu par le site pour distinguer un succes
  (`transmettre()` doit retourner `true`) d'un echec a retenter.
- Confirmation ou ajustement de la structure de payload ci-dessus (champs
  requis/optionnels du cote CRM, correspondance `bien_id`).
- Comportement souhaite en cas d'echec permanent (au-dela des 5 tentatives) :
  alerte, tableau de bord dedie, ou statu quo (consultation manuelle en
  admin) ?

### 2 ter. Mapping formulaire -> table -> ressource admin

Toutes ces ressources sont regroupees dans le menu admin sous **"Formulaires
recus"**. Ce sont des soumissions entrantes en lecture seule (consultation +
suppression), jamais du contenu cree depuis l'admin — voir `docs/rgpd.md`
pour l'inventaire des donnees personnelles concernees et la duree de
conservation.

| Formulaire (maquette)                          | Table                     | Modele Eloquent       | Ressource Filament                | Route de reception       |
|-------------------------------------------------|---------------------------|------------------------|-------------------------------------|---------------------------|
| Contact — branche maison / question              | `messages_contact`        | `MessageContact`       | `MessageContactResource`            | `POST /leads/contact`     |
| Contact — branche terrain (section + modale)     | `leads_terrain`           | `LeadTerrain`           | `LeadTerrainResource`               | `POST /leads/terrain`     |
| Vendre mon terrain — page (section + modale)     | `leads_terrain`           | `LeadTerrain`           | `LeadTerrainResource`               | `POST /leads/terrain`     |
| Planifier une visite (page Bien)                 | `demandes_visite`         | `DemandeVisite`         | `DemandeVisiteResource`             | `POST /leads/visite`      |
| Infos / brochure / prix / rappel (page Bien)     | `demandes_info_bien`      | `DemandeInfoBien`       | `DemandeInfoBienResource`           | `POST /leads/info-bien`   |
| Rappel generique (Contact, Acheter, Partenaires) | `demandes_info_bien`      | `DemandeInfoBien`       | `DemandeInfoBienResource`           | `POST /leads/info-bien`   |
| Candidature offre d'emploi / spontanee           | `candidatures`            | `Candidature`           | `CandidatureResource`               | `POST /leads/candidature` |
| Devenir partenaire / sous-traitant               | `candidatures_partenaires` + `candidature_partenaire_photos` | `CandidaturePartenaire` | `CandidaturePartenaireResource`     | `POST /leads/partenaire` puis `POST /leads/partenaire/photos` |
| Telecharger le guide (aside page Actualites)     | `telechargements_guide`   | `TelechargementGuide`   | `TelechargementGuideResource`       | `POST /leads/guide`       |

Points de conception a connaitre avant de toucher a ce lot :

- **La candidature partenaire est un formulaire de QUALIFICATION en 4 etapes**
  (2026-08-28), et non un simple formulaire de contact : metiers et effectif,
  zone d'intervention et disponibilite, modalites de collaboration, puis
  identite. Il reprend le perimetre de l'ancien plugin WordPress
  `biome-partenaires`, jamais fusionne sur la branche deployee de ce depot.

  Trois consequences a connaitre :

  1. **`metiers` et `zones` sont des colonnes JSON**, plus des chaines
     concatenees ", ". C'est ce qui rend l'admin filtrable par metier et par
     zone (`whereJsonContains`) — l'usage reel de cet ecran etant "trouver un
     carreleur disponible en province de Namur". Les valeurs stockees sont les
     libelles EN CLAIR de `config/site.php` : modifier ces listes n'invalide
     aucune candidature deja recue.
  2. **Le TELEPHONE est demande a l'etape 1**, l'identite complete restant a
     l'etape 4 (ordre du formulaire en ligne, pense pour la conversion).
     C'est ce qui rend l'enregistrement PARTIEL utile — `candidatures_
     partenaires.partiel`, meme principe que `leads_terrain.partiel` : la ligne
     creee des l'etape 1 est un prospect **reellement joignable**, meme si le
     candidat abandonne ensuite.

     > Une premiere version de cette section affirmait l'inverse (« pas
     > d'enregistrement partiel, une ligne creee a l'etape 1 n'aurait aucun
     > moyen de recontact »). C'etait exact au regard de la branche de code
     > alors consultee, ou le telephone n'arrivait qu'a l'etape 4 — et faux au
     > regard du formulaire reellement en ligne.

     L'id de la ligne partielle circule dans un champ cache. Deux garde-fous
     avant de la completer : l'id doit avoir ete cree par **ce** visiteur
     (memorise en session), et la ligne doit encore etre marquee `partiel`.
     Sans le premier, un identifiant devine suffirait a ecraser la candidature
     d'un tiers ; sans le second, un retour navigateur permettrait de reecrire
     une candidature deja envoyee.

  2bis. **`email` est facultatif**, comme sur le formulaire en ligne :
     beaucoup d'artisans n'en ont pas, et le telephone est la vraie prise de
     contact. La colonne est donc nullable — ce qui est aussi indispensable a
     l'enregistrement partiel, l'e-mail n'etant demande qu'a l'etape 4.
  3. **Les photos de realisations sont une etape FACULTATIVE proposee APRES
     l'enregistrement** (`POST /leads/partenaire/photos`). Le droit d'envoyer
     des photos est porte par un jeton en session, seul lien entre le visiteur
     et sa candidature : sans lui, impossible d'attacher des fichiers a la
     candidature d'un tiers en devinant son identifiant. Les fichiers vivent
     sur le disque PRIVE (chantiers de clients tiers) et ne sont servis qu'a un
     administrateur authentifie.

- **Le numero de TVA en doublon n'est PAS refuse**, contrairement au plugin
  WordPress. Le principe du site est que le lead est toujours enregistre ; le
  doublon est simplement signale dans la fiche admin
  (`CandidaturePartenaire::tvaEnDoublon`). Le numero est stocke sous forme
  canonique `BE` + 10 chiffres, sinon deux ecritures du meme numero ne se
  rapprocheraient pas.

- **Les candidatures de l'ancien site WordPress sont reprises** par
  `app:importer-partenaires-wordpress` : 309 candidatures, 758 notes de suivi,
  555 photos, 181 fiches BCE et 25 questionnaires metier. Tables d'accueil :
  `candidature_partenaire_notes`, `candidature_partenaire_tags`, et la colonne
  JSON `questionnaire_metier`.

  L'import est **idempotent** (rattachement par `wordpress_id` sur les
  candidatures, notes, etiquettes et photos) et dote d'un mode `--dry-run`. Il
  lit le dump SQL directement, sans base intermediaire.

  Choix a connaitre : les notes importees n'ont **pas d'auteur local** (les
  comptes WordPress ne correspondent a aucun utilisateur ici ; leur attribuer
  un compte reviendrait a preter des propos a quelqu'un), les dates d'origine
  sont **conservees** (l'anciennete pilote la purge RGPD), et les metiers
  desactives du plugin sont **gardes tels quels** plutot que supprimes. Detail
  complet : [`plugin-sst-wordpress-inventaire.md`](plugin-sst-wordpress-inventaire.md).

  L'**`uuid` d'origine est repris**, et non regenere. C'est la cle d'acces au
  questionnaire metier (ci-dessous) : un lien deja envoye a un sous-traitant
  par l'ancien site continue de fonctionner.

  Un defaut de l'ancien plugin est **signale mais pas corrige** : 54 valeurs
  de `organismes_controle` sont en realite des noms de marques (Niko,
  Legrand, Hager, ABB, Schneider), les deux groupes de cases etant adjacents
  et collectes par le meme selecteur cote WordPress. Les valeurs restent en
  base et la fiche les affiche telles quelles : reecrire la reponse d'un
  sous-traitant sur une hypothese serait pire que l'afficher imparfaite. Une
  nouvelle reponse a son questionnaire corrige la ligne.

- **`statut` a remplace le booleen `traite`** pour cette seule section. Le
  pipeline suit la version du plugin REELLEMENT EN SERVICE : nouveau, a
  contacter, **RDV fixe**, en mission, suspendu, **perdu**, archive. L'action
  partagee `BasculerTraiteAction` ne s'y applique donc plus, remplacee par une
  action de changement de statut.

  `qualifie` est conserve **en plus**, libelle « Qualifie (historique) » : c'est
  l'etat qu'a recu chaque candidature dont l'ancien booleen `traite` valait
  true. Il reste affichable pour ne pas perdre le travail deja fait par
  l'equipe, mais n'est plus propose (voir
  `CandidaturePartenaire::statutsProposables()`).

  Trois statuts ajoutes le 2026-09-16 a la demande du client : « En attente
  RDV » (entre « A contacter » et « RDV fixe » — l'etat qui suit l'envoi du
  modele d'e-mail demandant trois disponibilites), « Ne convient pas pour le
  ST » et « Ne convient pas pour Biome ».

  Leurs CLES sont plus courtes que leurs libelles (`refuse_par_biome`), et la
  colonne est passee de 20 a 40 caracteres : `ne_convient_pas_biome` en
  aurait fait 21. En base stricte MySQL REFUSE l'ecriture — la panne serait
  apparue a l'usage, au premier changement de statut, et non au
  deploiement. Un test verifie desormais que chaque cle tient dans la
  colonne.

- **Les fiches sans aucun moyen de contact sont masquees par defaut** dans le
  tableau admin (filtre « Fiches injoignables », `TernaryFilter` a `false` par
  defaut, appuye sur `CandidaturePartenaire::scopeSansMoyenDeContact`). 87 des
  320 candidatures de production sont dans ce cas : l'ancien plugin creait la
  ligne des que le visiteur touchait l'etape 1, avant de lui demander comment
  le joindre.

  Le critere porte sur le MOYEN DE CONTACT (telephone ou e-mail) et **non sur
  `partiel`** : le telephone est desormais exige a l'etape 1, donc tout abandon
  recent reste joignable — et c'est le lead le plus chaud qui soit. Masquer les
  profils partiels aurait enterre la raison d'etre de l'enregistrement partiel.
  Le filtre les rappelle a l'ecran : masquer n'est pas supprimer.

  Piege Filament a connaitre : `formatStateUsing()` n'est PAS appele quand la
  colonne vaut `null` (le placeholder court-circuite le formatage), et la
  `description()` d'une colonne dont l'etat est vide n'est pas rendue du tout.
  La colonne « Contact » utilise donc `state()`, et retombe sur « Contact
  inconnu » : sans quoi la ligne s'affichait entierement vide, ce qui ressemble
  a une anomalie de l'application plutot qu'a une donnee fidelement reprise.

- **Le tableau des candidatures doit tenir dans la largeur de l'ecran.** La
  barre de defilement horizontale de Filament se trouve SOUS le tableau : il
  faut donc descendre en bas de page pour atteindre les dernieres colonnes,
  ce qui rend la table inutilisable des qu'elle deborde. Mesures avant/apres,
  conteneur de 1201 px :

  | | Avant | Apres |
  |---|---|---|
  | Largeur de la table | 2001 px | 1201 px |
  | dont colonne d'actions | **775 px** | 56 px |

  Quatre partis pris, dans l'ordre du gain : les actions sont regroupees dans
  un `ActionGroup` (six boutons cote a cote causaient a eux seuls la quasi
  totalite du debordement) ; la date de reception vit sous le nom, en relatif,
  et non dans une colonne a elle (146 px) ; telephone et e-mail partagent une
  colonne, parce que l'ecran sert a savoir qui appeler ; « Dispo » n'apparait
  qu'a partir de 1536 px (`visibleFrom('2xl')`), la recherche par
  disponibilite passant de toute facon par le filtre.

  Verifie au navigateur a 1366, 1600 et 1920 px : debordement nul aux trois
  largeurs.

- **Un onglet par etape du pipeline**, avec son compteur
  (`ListCandidaturePartenaires::getTabs()`). L'ancien plugin avait un tableau
  de bord affichant ces compteurs ; il n'avait pas ete porte, et « qui ai-je
  en RDV ? » demandait trois clics dans un filtre.

  Les compteurs **excluent les fiches injoignables**, comme la liste par
  defaut : un onglet annoncant 92 quand la liste en montre 5 ferait douter de
  l'application au lieu d'informer. Et l'onglet du statut historique
  `qualifie` n'apparait que tant que des candidatures le portent — il
  s'effacera de lui-meme.

- **Le suivi s'alimente depuis la FICHE**, et plus seulement depuis la liste.
  L'action « Ajouter une note » vit dans `App\Filament\Actions\n  AjouterNoteCandidatureAction` (meme principe que `BasculerTraiteAction`),
  partagee entre les deux ecrans : deux definitions recopiees finiraient par
  diverger.

  La section « Suivi » est **toujours affichee**, meme sans aucune note. Elle
  etait conditionnee a l'existence d'une note, et disparaissait donc
  precisement sur les candidatures a traiter — celles ou l'equipe veut
  consigner son premier appel. Le CONTENU de la candidature, lui, reste non
  modifiable : ce sont les reponses du sous-traitant.

  Deux elements de l'ancien plugin ne sont volontairement PAS repris : la
  piece jointe par note (table `biome_fichiers` vide en production, 0 ligne)
  et la suppression d'une note — 758 notes viennent de l'ancien site, ouvrir
  un moyen d'effacer des traces de travail partagees demande une decision
  explicite du client.

  Deux pieges Filament 5 rencontres ici : `Tab` vit dans
  `Filament\Schemas\Components\Tabs\Tab` (et non sous `Resources\Pages`), et
  Filament injecte la requete **par nom de parametre** — un parametre nomme
  autrement que `$query` lui fait resoudre un `Builder` vide depuis le
  conteneur, d'ou une erreur opaque
  (« newQueryWithoutRelationships() on null »).

  **Ce piege a mordu deux fois.** Sur les onglets il produisait une erreur
  franche ; sur les FILTRES de tableau, non : la fermeture recevait un
  `Builder` detache, le filtre s'appliquait dans le vide et la liste restait
  entiere, sans erreur ni avertissement. Les quatre filtres de
  `CandidaturePartenairesTable` sont restes inertes en production jusqu'au
  2026-09-16.

  Regle : **toute fermeture Filament qui recoit la requete nomme son
  parametre `$query`**, sans exception. Et un filtre se teste A TRAVERS
  l'ecran (`Livewire::test(...)->filterTable(...)`) : les deux tests qui le
  couvraient appelaient `whereJsonContains` directement sur le modele, ils
  validaient Eloquent et n'ont rien vu.

- **Les questionnaires metier sont refaits**, pas seulement importes. Deux
  questionnaires (electricien, chauffage/sanitaire) collectent les conditions
  de collaboration et la grille de prix, en 4 etapes, sur le meme parcours
  que le formulaire de candidature.

  Toute leur structure vit dans **`config/questionnaires-metier.php`** :
  etapes, champs, types, options, conditions d'affichage. Ce fichier pilote
  A LA FOIS le rendu de la vue et les regles de validation serveur
  (`App\Services\Partenaires\SpecificationQuestionnaire`) — ajouter une
  question ne demande ni migration ni code, meme principe que les listes de
  `config/site.php`.

  Les **cles de champs reprennent les noms de colonnes du plugin**. C'est ce
  qui permet aux 25 questionnaires importes et aux nouvelles reponses de
  partager une seule forme dans la colonne JSON `questionnaire_metier`,
  indexee par slug de questionnaire.

  Deux cles de meta accompagnent chaque bloc, aux memes noms que dans le
  plugin : `date_soumission` (premiere reponse, **jamais** ecrasee par une
  correction) et `date_modification`. Dans une grille d'achat, l'anciennete
  d'un prix change sa lecture ; les questionnaires importes qui n'en portent
  pas sont affiches comme « date de reponse inconnue » plutot que de passer
  pour recents. Une correction **preserve aussi les cles hors specification**
  heritees de l'ancien site (`iddomaine`) : le sous-traitant ne peut pas les
  ressaisir.

  La fiche admin (section « Questionnaires metier ») rend ces reponses avec
  les libelles reels des questions, groupees par etape et traduites depuis
  les cles d'options. Les cles inconnues de la specification y sont montrees
  a part, jamais masquees.

  **Acces sans compte.** La page est atteinte par
  `GET /partenaires/questionnaire/{metier}/{uuid}` : un sous-traitant n'a ni
  compte ni mot de passe, le lien EST l'acces. L'`uuid` et non l'id
  sequentiel, sinon un chiffre change donnerait la fiche du voisin. La page
  est en `noindex,nofollow`, pre-remplie des reponses deja connues (le
  sous-traitant peut donc se corriger), et chaque envoi cree une note
  `systeme` sur la candidature et notifie l'equipe. Le lien s'obtient dans
  l'admin par l'action « Lien questionnaire », proposee uniquement pour les
  questionnaires pertinents au vu des metiers coches.

  **Aucune relance automatique n'est prevue** (decision client du
  2026-09-07). L'ancien plugin en declarait deux (J+7, J+14) qui ne sont
  jamais parties ; le suivi se fait a la main, par les notes.

- **`leads_terrain` est une table unique** pour tous les points d'entree du
  formulaire "vendre mon terrain" : page dediee (section et modale) et
  branche terrain du formulaire Contact (section et modale). Le champ
  `origine` trace le point d'entree exact — pas de duplication de schema
  entre les deux pages. 4 valeurs possibles (`StoreLeadTerrainRequest`,
  regle `Rule::in`), une par instance du composant
  `<x-formulaire-terrain>` (voir `docs/frontend-composants.md`) :

  | Valeur `origine` | Page | Instance |
  |---|---|---|
  | `terrain_section` | `/vendre-mon-terrain` | Section fond sombre `#form-terrain` |
  | `terrain_modale` | `/vendre-mon-terrain` | Modale (CTA "PROPOSER MON TERRAIN" du hero et des relances) |
  | `contact_section` | `/contact` | Branche "terrain" du formulaire adaptatif |
  | `contact_modale` | `/contact` | Modale (CTA "Vendre mon terrain" du hero et bloc acces rapide) |

  - **Confirmation affichee dans la bonne instance (`succes_origine`).**
    Apres creation du lead, `LeadTerrainController::store()` flashe en plus
    de `succes` (le message) un `succes_origine` = valeur exacte du champ
    `origine` du lead cree. Chaque instance de `<x-formulaire-terrain>`
    compare `session('succes_origine')` a sa propre prop `origine` : seule
    l'instance qui a effectivement soumis affiche l'ecran de confirmation
    (et se rouvre automatiquement si c'est une modale) ; les 3 autres
    instances presentes sur la meme page restent inchangees. Sur la page
    Contact, `succes_origine` sert aussi a determiner quelle branche du
    formulaire adaptatif (maison/terrain/question) reafficher — voir
    `docs/frontend-composants.md` pour le detail cote vue et JS.
    `MessageContactController::store()` applique le meme mecanisme pour ses
    2 branches (`succes_origine` = `contact_maison` ou `contact_question`).
    Sans `succes_origine` en session (ex. ancienne session), repli sur la
    prop `succesParDefaut` du composant pour rester robuste.
- **`demandes_info_bien` regroupe 4 types de demande** via le champ `type`
  (`infos`, `brochure`, `prix`, `rappel`), y compris le **rappel generique**
  sans bien associe (`bien_id` nullable, utilise par Contact / Acheter /
  Partenaires) et le formulaire sidebar "Interesse par ce bien ?" de la page
  Bien (qui remplit en plus `souhaits`, un JSON des cases cochees : rappel,
  brochure, visite).
- **Fichiers de candidature en stockage prive.** Le CV (obligatoire) et la
  lettre de motivation (optionnelle) sont uploades sur le disque `local`
  (`storage/app/private/candidatures/...`), jamais dans `public/`. Ils sont
  telechargeables uniquement via deux routes protegees par le middleware
  `auth` : `admin.candidatures.cv` et `admin.candidatures.lettre-motivation`
  (controleur `App\Http\Controllers\Admin\CandidatureFichierController`).
  Meme logique pour les photos de terrain (`leads_terrain.photos`, JSON de
  chemins sur le disque prive).
- **Guide telechargeable : lead MySQL standard + PDF gere via `ContenuPage`.**
  Conformement a la decision client du 2026-07-03 (section 2 bis), le
  formulaire "Telecharger le guide" (aside de la page Actualites) cree un
  lead classique dans `telechargements_guide`, mais le PDF lui-meme n'est PAS
  stocke dans cette table : il est uploade par un administrateur dans
  `ContenuPageResource` (page `actualites`, bloc `guide.fichier_pdf`, disque
  `public`, dossier `guides`) — voir section 2 quater/quinquies pour le
  fonctionnement general de `ContenuPage`. Cote reception du formulaire,
  `TelechargementGuideController::store()` lit ce chemin via
  `ContenuPage::pourPage('actualites')` : s'il est renseigne, l'URL publique
  du PDF est flashee en session (`guide_pdf_url`) pour que la vue propose un
  telechargement immediat ; sinon un message honnete indique que le guide
  sera envoye par e-mail (aucune fausse promesse de telechargement).
- **`candidatures.offre_emploi_id` est un `varchar` sans cle etrangere**,
  car le CRUD `OffreEmploi` n'existe pas encore dans ce lot. A faire evoluer
  en veritable cle etrangere des que ce CRUD sera livre (lot suivant).
- **Notification par e-mail synchrone.** Chaque soumission envoie un e-mail
  interne via `App\Mail\NouveauLeadMail` a `config('site.email_reception_
  leads')` (voir README, variable `MAIL_RECEPTION_LEADS`). L'envoi est
  synchrone (pas de queue worker demon sur le mutualise OVH, voir
  `docs/deploiement-ovh.md`).
- **Anti-abus** : les 6 routes `POST /leads/*` sont limitees a 10 requetes
  par minute et par IP (`throttle:10,1`), pas de captcha pour l'instant.
- **RGPD** : voir `docs/rgpd.md` pour le detail des champs personnels
  collectes par formulaire, la finalite, l'acces admin et la duree de
  conservation (actuellement 24 mois par defaut, EN ATTENTE DE VALIDATION —
  purge automatique non encore implementee).

### 2 quater. Actualites et Offres d'emploi — detail des ressources admin

Ces deux sections sont du contenu editorial classique (creation/edition/
suppression depuis l'admin), regroupees dans le menu Filament **"Contenu du
site"**. Guide pratique pour l'equipe Biome : `docs/contenu-editorial.md`.

**Actualites** (guide Biome, page `/actualites`)

| Element | Detail |
|---|---|
| Table | `actualites` |
| Modele | `App\Models\Actualite` |
| Ressource Filament | `App\Filament\Resources\Actualites\ActualiteResource` |
| Controleur public | `App\Http\Controllers\ActualiteController` (`index`) |
| Vue | `resources/views/pages/actualites.blade.php` |
| Champs | `titre`, `slug` (auto-genere depuis le titre si laisse vide, unique), `extrait`, `contenu`, `categorie` (une des 5 valeurs figees : construire, terrain, investir, fiscalite, conseils — voir `ActualiteForm::categories()`), `image` (upload, disque `public`, dossier `actualites`), `date_publication` (permet une publication planifiee dans le futur), `temps_lecture` (texte libre, ex. "6 min"), `populaire` (booleen) |
| Regles metier | Seuls les articles dont `date_publication <= maintenant` apparaissent sur le site (scope `publiees()`). Le bloc "Articles populaires" de la sidebar affiche jusqu'a 3 articles publies avec `populaire = true` (scope `populaires()`). |

**Offres d'emploi** (recrutement, pages `/offres-emploi` et `/offres-emploi/{id}`)

| Element | Detail |
|---|---|
| Table | `offres_emploi` |
| Modele | `App\Models\OffreEmploi` |
| Ressource Filament | `App\Filament\Resources\OffresEmploi\OffreEmploiResource` (slug d'URL admin explicite `offres-emploi`) |
| Controleurs publics | `App\Http\Controllers\OffreEmploiController` (`index`, `show`) et `App\Http\Controllers\RecrutementController` (`index`, page `/recrutement`, 4 offres max) |
| Vues | `resources/views/pages/offres.blade.php` (liste), `resources/views/pages/offre.blade.php` (detail), `resources/views/pages/recrutement.blade.php` |
| Champs | `titre`, `slug` (auto), `lieu` (defaut "Gedinne"), `type_contrat` (defaut "CDI"), `temps_travail` (defaut "Temps plein"), `description` (courte, affichee en liste), `missions` / `profil` / `avantages` (chacun saisi **une entree par ligne** dans un simple `Textarea`, eclate en `<li>` cote vue avec `explode`/`preg_split`), `nouveau` (badge liste), `active` (l'offre disparait du site public si desactivee, sans etre supprimee), `ordre` (tri manuel, plus grand = plus haut) |
| Regles metier | Seules les offres `active = true` sont visibles publiquement (scope `actives()`), triees par `ordre` desc puis date de creation desc. |
| Lien avec les candidatures | `candidatures.offre_emploi_id` est une **veritable cle etrangere** (`foreignId` nullable, `nullOnDelete`) vers `offres_emploi.id` — voir migration `2026_07_02_165022_convertir_offre_emploi_id_en_cle_etrangere_sur_candidatures.php`. Nullable pour les candidatures spontanees (sans offre precise). Cette conversion remplace l'ancien `varchar` sans contrainte du lot precedent (voir historique Git) : a la suppression d'une offre, les candidatures liees sont conservees avec `offre_emploi_id = null` plutot que supprimees. |

**Contenu seede depuis la maquette (recette visuelle)**

`php artisan migrate --seed` execute `ActualiteSeeder` (6 articles) et
`OffreEmploiSeeder` (6 offres), avec le contenu reellement observe dans
`maquettes/Actualites.dc.html`, `maquettes/Offres.dc.html` et
`maquettes/Offre.dc.html`. Objectif : obtenir un site pre-rempli identique a
la maquette pour une recette visuelle, sans devoir saisir du contenu a la
main. Points a connaitre :

- **Corps d'article provisoire.** La maquette ne fournit qu'un extrait par
  article, jamais de corps complet. Le seeder copie donc l'extrait dans le
  champ `contenu` a titre provisoire : c'est un texte de demonstration, pas
  une redaction finale (voir `docs/contenu-editorial.md`).
- **Incoherence de la maquette sur les articles populaires.** La maquette
  source annonce 3 articles populaires dans son bloc "popular", mais un des
  trois ("Vendre son terrain : les etapes cles") ne correspond a aucun des 6
  articles listes ailleurs dans la maquette. Le seeder ne reproduit donc que
  les 2 articles populaires reels et coherents.
- **Detail d'offre non personnalise.** Dans `maquettes/Offre.dc.html`, les
  blocs missions/profil/avantages/FAQ sont identiques quelle que soit l'offre
  consultee (pas de contenu par offre dans l'export). Le seeder reutilise donc
  le meme texte de missions/profil/avantages pour les 6 offres ; la FAQ,
  elle, n'a pas de champ dedie et reste figee directement dans
  `resources/views/pages/offre.blade.php`.

### 2 quinquies. Qui-sommes-nous, Investir et Realisations — detail des ressources admin

**Contenu de page (Qui-sommes-nous et Investir)**

Ces deux pages rejoignent `ContenuPageResource`, la meme ressource que
l'Accueil (voir section 2 ci-dessus), avec chacune sa propre cle `page` dans
`contenus_pages` et son propre schema de blocs. Le schema (champs par bloc et
valeurs par defaut) est centralise dans
`App\Filament\Resources\ContenusPages\Config\SchemaContenuPage::pages()` :
c'est la source unique consultee par le formulaire admin (`ContenuPageForm`),
par le seeder (`ContenuPageSeeder`) et par `PageController` en repli si la
page n'a pas encore ete seedee.

| Element | Detail |
|---|---|
| Table | `contenus_pages` (`page = 'qui-sommes-nous'` ou `page = 'investir'`) |
| Modele | `App\Models\ContenuPage` |
| Ressource Filament | `ContenuPageResource` (menu "Contenu du site" > "Contenu de page"), meme ressource que l'Accueil — une fiche par page, pas de creation ni suppression |
| Controleur public | `App\Http\Controllers\PageController` (`quiSommesNous`, `investir`) |
| Vues | `resources/views/pages/qui-sommes-nous.blade.php`, `resources/views/pages/investir.blade.php` |
| Blocs editables (Qui-sommes-nous) | `hero` (label, titre, 3 paragraphes, textes de boutons, image), `valeurs` (titre + 4 valeurs titre/texte), `histoire` (titre, 2 paragraphes, liste "ambition" une entree par ligne, 3e paragraphe, image), `pourquoi` (titre + 3 items titre/texte), `independance` (titre, texte, liste "forces", image, texte de carte), `equipe` (titre, texte, image), `realisations` (titre de section uniquement — les photos viennent de `RealisationResource`, voir ci-dessous), `cta` (titre, texte, textes de boutons) |
| Blocs editables (Investir) | `hero` (label, titre, texte, textes de boutons, image), `avantages` (titre + 4 items titre/texte), `opportunites` (label, titre, textes de boutons — **pas les biens eux-memes**, voir ci-dessous), `process` (titre + 4 etapes titre/texte, image), `cta` (titre, texte, texte de bouton du bandeau email) |
| Icones et liens de bouton | Fixes dans le code (SVG inline dans la vue pour les icones, `route(...)` pour les destinations) — jamais stockes en base, meme convention que l'Accueil. |

**Important pour Investir : la section "Biens d'investissement" n'est PAS
geree par `ContenuPageResource`.** Elle est alimentee exclusivement par le
CRM via `CrmClientInterface::biensInvestissement()` (voir section 3) :
aucune saisie manuelle en admin, aucune table MySQL dediee. Seuls les textes
autour de cette section (titre, label, textes de bouton du bloc
`opportunites`) sont editables.

**Realisations** (galerie de chantiers termines, carrousel de la page
Qui-sommes-nous)

| Element | Detail |
|---|---|
| Table | `realisations` |
| Modele | `App\Models\Realisation` |
| Ressource Filament | `App\Filament\Resources\Realisations\RealisationResource` (menu "Contenu du site") |
| Controleur public | `App\Http\Controllers\PageController@quiSommesNous` |
| Vue | `resources/views/pages/qui-sommes-nous.blade.php`, bloc "REALISATIONS" |
| Champs | `titre` (legende principale de la photo), `legende` (texte complementaire optionnel), `image` (upload, disque `public`, dossier `realisations`, optionnelle), `ordre` (tri manuel croissant, liste admin reorganisable par glisser-deposer) |
| Regles metier | Toutes les realisations sont affichees (pas de statut actif/inactif), triees par `ordre` croissant (scope `Realisation::scopeOrdonnees()`). Sans image, un bloc neutre reprenant le titre/legende s'affiche a la place — le site ne casse jamais visuellement, meme comportement que les partenaires/actualites sans image. |
| Contenu seede | `RealisationSeeder` cree 6 realisations avec le titre observe dans `maquettes/Qui-sommes-nous.dc.html` (bloc "REALISATIONS"), **sans image** (la maquette n'expose pas de vraies photos a cet endroit, seulement des slots vides) — a completer via l'admin des que de vraies photos de chantier sont disponibles. |

## 3. Contrat CrmClient (lecture seule) — a convenir avec l'equipe CRM

**Document de coordination a apporter en reunion avec l'equipe CRM :
`docs/contrat-crm-champs.md`.** Il reprend, sous forme autonome et en
tableaux, ce que le site doit lire du CRM (biens, detail, investissement,
documents/medias) ET ce qu'il lui transmet en retour (leads acheteurs, voir
sous-section "Transmission des leads vers le CRM" ci-dessus) — la section
ci-dessous reste la reference technique cote code pour l'equipe du site.

**Statut actuel : implementation bouchon active, enveloppee dans un cache
court.** `App\Services\Crm\FixtureCrmClient` sert 9 biens fixtures issus de la
maquette depuis `app/Services/Crm/fixtures/biens.php`. Cette classe est en
attente du contrat CRM reel avec le **Portail vendeur** ; elle sera remplacee
par une implementation HTTP le jour ou ce contrat est fixe, sans changer
`CrmClientInterface` ni le code appelant (controleurs, vues).

Entre les controleurs et `FixtureCrmClient` se trouve un decorateur de cache,
`App\Services\Crm\CrmClientEnCache` : il implemente la meme interface, met en
cache (driver de `CACHE_DRIVER`) chaque appel a `biens()`, `bien()`,
`photos()` et `biensInvestissement()`, et delegue ensuite au client reel. C'est la mise en oeuvre
concrete de l'exigence CLAUDE.md "cache court cote site, jamais consideree
comme modifiable" : le CRM reste la seule source de verite, ce decorateur ne
fait que soulager la performance en cas d'appels repetes.

- **Binding** : `App\Providers\AppServiceProvider::register()` construit
  `new CrmClientEnCache(new FixtureCrmClient(), $ttl)` et le lie en singleton
  sur `CrmClientInterface`. Le jour ou `FixtureCrmClient` est remplace par un
  vrai client HTTP, il suffit de changer cette ligne : ni le decorateur, ni
  les controleurs, ni les vues n'ont besoin d'evoluer.
- **TTL** : `config('services.crm.cache_ttl')`, alimentee par la variable
  d'environnement `CRM_CACHE_TTL` (defaut **900 secondes**, soit 15 minutes).
  A ajuster dans `.env` selon la fraicheur souhaitee vs la charge sur le CRM.
- **Cles de cache** : prefixees `crm.v1.` (ex. `crm.v1.bien.ach-1`,
  `crm.v1.biens.<hash des filtres>`, `crm.v1.photos.ach-1`,
  `crm.v1.biens-investissement`). Le `v1` permet
  d'invalider tout le cache CRM en l'incrementant dans le code si la
  structure des donnees retournees par le CRM change un jour, sans attendre
  l'expiration naturelle du TTL.
- **Lecture seule stricte** : le decorateur n'expose aucune methode
  d'ecriture ni de purge manuelle du cache pour l'instant (la purge se fait
  par expiration du TTL). A prevoir si une purge a la demande devient
  necessaire (ex. bouton "rafraichir" cote CRM).

CRM cible : **Portail vendeur** (CRM interne Biome, developpe avec Claude Code).
Le site et le CRM sont deux systemes distincts : le mode d'acces (endpoints,
champs, auth, acces aux photos/PDF) doit etre CONVENU avec l'equipe du CRM.
Ce contrat est le livrable a negocier ; tant qu'il n'est pas fixe, l'interface
ci-dessous reste servie par `FixtureCrmClient` pour ne pas bloquer le front.

Le tableau ci-dessous liste ce que le SITE a besoin de recevoir (deduit de la
maquette) — c'est la base de discussion a apporter a l'equipe CRM.

Champs attendus d'apres la maquette (page Bien + PropertyCard) :
- Liste : id, titre, prix, localite, surface habitable, chambres, surface terrain,
  photo principale.
- Detail : galerie de photos (source unique = CRM), description, informations
  cles, detail du prix (+ PDF eventuel), plans (PDF), situation (adresse /
  coordonnees pour la carte), arguments "pourquoi cette maison", biens similaires.
- Investissement (page Investir, section "Biens d'investissement", **A CONVENIR**) :
  loyer estime, rendement locatif, type de bien tel qu'affiche sur la carte
  investisseur (peut differer du libelle de facades standard). Voir le champ
  `investissement` documente dans `app/Services/Crm/fixtures/biens.php` et la
  methode `biensInvestissement()` ci-dessous.

Interface indicative :

```php
interface CrmClient
{
    // Liste paginee / filtree des biens a vendre
    public function biens(array $filtres = []): array;

    // Detail d'un bien par identifiant CRM
    public function bien(string $id): ?array;

    // URLs des photos d'un bien (stockees uniquement dans le CRM)
    public function photos(string $id): array;

    // Biens mis en avant pour l'investissement locatif, avec leurs donnees
    // d'investissement (loyer estime, rendement...) - A CONVENIR avec le CRM
    public function biensInvestissement(): array;
}
```

Points a trancher avec le CRM :
- Les photos sont-elles servies par URL publique (hotlink) ou faut-il les
  proxifier / mettre en cache cote site (perf + eventuelle auth) ?
- Les PDF (plans, detail prix) viennent-ils aussi du CRM ? Cote site, le
  bouton "Detail du prix" de la page Bien est deja cable sur une route
  (`bien.pdf-prix`, voir `BienController::pdfPrix()`) mais celle-ci renvoie
  volontairement une reponse HTTP 501 "Not Implemented" tant que ce point
  n'est pas tranche : pas de generation de PDF cote site (aucune dependance
  type DomPDF ajoutee), le bouton correspondant est affiche desactive cote
  front (`resources/views/components/modale-lead.blade.php`, ecran de
  confirmation du type `prix`).
- Le CRM fournira-t-il des images de plans (rez-de-chaussee, etage,
  implantation terrain) ? La page Bien affiche aujourd'hui des placeholders
  neutres a cet endroit : aucun champ "plans" n'existe dans les fixtures
  CRM actuelles (`app/Services/Crm/fixtures/biens.php`), et la maquette
  source elle-meme n'en affichait pas non plus (image-slot vide).
- Le CRM fournira-t-il des coordonnees (latitude/longitude ou adresse
  structuree) pour afficher une vraie carte de situation sur la page Bien ?
  Idem, la maquette et les fixtures actuelles n'exposent qu'un placeholder
  "Carte — {localite}", sans donnee de geolocalisation exploitable.
- Frequence de rafraichissement acceptable (TTL du cache) ? Valeur actuelle
  cote site : 900 secondes (15 min), configurable via `CRM_CACHE_TTL` — voir
  decorateur `CrmClientEnCache` ci-dessus.
- Le CRM exposera-t-il un vrai flag/critere "bien mis en avant pour
  l'investissement" avec loyer estime et rendement calcules cote CRM ? Cote
  site, `biensInvestissement()` est aujourd'hui servie par
  `FixtureCrmClient` en filtrant les biens fixtures qui portent une cle
  `investissement` (voir `app/Services/Crm/fixtures/biens.php`) : c'est une
  simulation, pas un contrat fixe.
- **Titre affiche sur les cartes "Biens d'investissement" (page Investir) :
  aucun champ dedie dans le contrat CRM actuel.** La maquette
  (`maquettes/Investir.dc.html`) affiche un libelle synthetique par carte
  (ex. "Appartement 2 chambres", "Studio neuf meuble"), mais ce libelle
  n'existe pas tel quel dans les donnees d'un bien. Cote site,
  `resources/views/pages/investir.blade.php` reutilise donc `$bien['facades']`
  (le meme champ que celui affiche comme "titre" d'un bien standard sur
  `pages/bien.blade.php`), alors que les fixtures exposent deja un champ
  distinct `investissement.type_investissement` pensable pour cet usage (ex.
  "Duplex", "Studio", "Appart.") mais volontairement different de `facades`
  dans les fixtures actuelles (voir PHPDoc de
  `app/Services/Crm/fixtures/biens.php`). A trancher avec l'equipe CRM :
  faut-il un champ `titre_investissement` dedie, ou le site doit-il composer
  son propre libelle a partir de `type_investissement` et de la localite ?

## 4. Tokens de design (extraits de la maquette)

**Reference operationnelle : `public/css/tokens.css`**, documente en detail
dans `docs/design-tokens.md` (nom de chaque token, valeur, usage). Ce fichier
CSS est charge dans `resources/views/layouts/site.blade.php` et fait foi ; il
ne faut pas s'appuyer sur le resume ci-dessous pour coder, seulement pour une
vue d'ensemble rapide.

Resume (voir `docs/design-tokens.md` pour le detail complet) :
- Polices : Poppins (titres, 500-800), Mulish (texte, 400-800), via Google Fonts.
- Bleu principal : #1C5BB8 (+ variantes hover/claire : #2467CC, #2E7BE4, #4089EE)
- Texte fonce : #122A4A (et #1B2A3D pour les titres de carte)
- Texte attenue (fond clair) : #5C6B7E
- Zone sombre (footer, sticky) : fond #0E2440/#16335A, textes attenues
  #7E92AE/#A9B8CD/#9AA6B6
- Fonds clairs / survols / hero : #EAF1FB, #DCE8F7, #E8EFF7, #FBFCFE
- Bordures : #E4EAF2, #E8EDF4, #DCE6F1, #D6DFEC, #CBD8E8, #EEF2F7
- Rayons : cartes 14/16/18px, boutons 9/10px, nav 12px, badge 5px
- Ombres : rgba(18,42,74,.04 a .18), base rgba(14,36,64,.28) pour le sticky
- Espacements de section : 48/56/60/64/68/72px
- Largeur max conteneur : 1280px (1100px variante etroite), padding lateral 32px

Note : la maquette est la source de verite. Plusieurs tokens ci-dessus
(variantes bleu, zone sombre, rayons/ombres supplementaires, echelle
d'espacement de section) ont ete releves directement dans la maquette lors de
l'implementation et n'apparaissaient pas dans une premiere version de cette
liste — voir `docs/design-tokens.md` pour le detail des ecarts.

## 5. Note technique : format de l'export Claude Design

L'export utilise le runtime Claude Design (`support.js`, balises `<x-dc>`,
`sc-for`, `sc-if`, templating `{{ }}`, logique dans une classe `DCLogic`
avec `renderVals()`). Ce n'est PAS du HTML statique directement portable.
dev-frontend porte ces composants en Blade :
- `sc-for`  -> `@foreach`
- `sc-if`   -> `@if`
- `{{ }}`   -> variables Blade / composants
- `DCLogic.renderVals()` -> donnees fournies par le controleur

Les styles (inline) et les tokens ci-dessus se transferent tels quels : la
fidelite visuelle est atteignable a l'identique.
