# CLAUDE.md — Session Uluana 2026-06-27

## Contexte projet
Uluana — plateforme de développement business pour artisans créateurs.
Deux repos GitHub (branche active : `develop`) :
- Front : `sfauquant/uluana`
- Back : `sfauquant/uluana_server`
- Token GitHub : fourni en début de session

## Architecture déployée (o2switch, user : fase0445)
```
~/develop/uluana/frontend     → dev.uluana.art
~/develop/uluana/backend      → dev-api.uluana.art
~/public_html/uluana/frontend → uluana.art
~/public_html/uluana/backend  → api.uluana.art
~/.env                        → prod
~/.env.dev                    → dev
~/deploy-staging.sh           → cron * * * * *
~/deploy-prod.sh              → cron 0 * * * *
~/logs/dev.log / prod.log
```

## Règles absolues
- Toujours pousser sur `develop` uniquement
- Merge sur `main` uniquement sur validation explicite de Mr Fauquant
- Classes CSS réutilisées entre fichiers — non-redondance prioritaire
- Tous les appels API via `DataManager.url('/api/...')` — jamais de chemins hardcodés
- Valeurs `.env` avec caractères spéciaux entre guillemets (`"AmuH-Jz8W-4bc@"`)
- **Mr Fauquant est le seul à décider des changements fonctionnels.** Claude ne modifie jamais un comportement (visibilité, flux, logique UX) sans que ce soit explicitement demandé. Si Claude identifie un problème fonctionnel lors d'un bug fix, il le signale et propose — mais n'agit pas sans validation.
- **Quand Mr Fauquant dit explicitement de laisser un sujet en suspens** (reporté à plus tard, pas maintenant), l'ajouter à la section **TODO / En suspens** ci-dessous avec la date du jour. Retirer l'entrée dès qu'elle est traitée. **Ne pas y ajouter un projet juste parce qu'il avance par étapes** — tant qu'on travaille dessus activement (même sur plusieurs sessions) et qu'il n'est pas explicitement mis en pause, ce n'est pas une entrée TODO.

## TODO / En suspens

*Liste vivante — un point disparaît dès qu'il est traité, peu importe qui l'a réglé.*

### Ajoutés le 2026-07-30
- [ ] **Repasse Hormozi sur les 24 parcours existants** : certains commencent par "comprendre" avant d'"agir" — revoir la sélection de cartes, l'ordre des modules et les `tip` pour coller à la logique volume/vitesse/action. Attendre les retours clients de Mr Fauquant avant de commencer — les feedbacks terrain orienteront quels parcours prioriser.

### Ajoutés le 2026-07-28 (pages légales)
- [ ] Confirmer le statut TVA (franchise en base ou non — seuil micro-entreprise)
- [ ] Confirmer si « uluana.art » + logo sont réellement déposés à l'INPI (sinon retirer la mention « marque déposée »)
- [ ] Mettre en place un vrai outil de gestion du consentement cookies (Axeptio ou tarteaucitron.js) — la bannière actuelle est un placeholder texte, pas fonctionnelle
- [ ] Faire valider par un avocat la clause de limitation de responsabilité (mentions légales / CGU art. 6 / CGV art. 6)
- [ ] Mettre à jour au RCS/Guichet unique INPI : objet social (activité déclarée sans rapport avec Uluana), adresse (Bayonne → Saint-Pierre-d'Irube), nom de domaine (`uluana.fr` → `uluana.art`)
- [ ] Rédiger les CGV spécifiques par offre (Essentiel/Accompagnement, paiement différé, parrainage 40%) — les CGV génériques publiées y renvoient sans les détailler

## Stack technique
- Front : Vanilla JS SPA, router custom `index.js`, boot script inline `index.html`
- Back : PHP 8+, Apache, MySQL/PDO, JWT HS256
- Auth : JWT, Google OAuth (GSI), magic link email
- Upload : WebP 80%, resize auto
- CSS : fichiers split dans `css/split/`, `main.css` importe tout
- Select2 chargé depuis CDN dans `index.html`

## config.json — multi-environnements
```json
{
  "environments": {
    "local":  { "api_base": "http://uluana-server.local:8080", "assets_base": "", "test_accounts_enabled": true },
    "dev":    { "api_base": "https://dev-api.uluana.art",      "assets_base": "", "test_accounts_enabled": true },
    "prod":   { "api_base": "https://api.uluana.art",          "assets_base": "", "test_accounts_enabled": false }
  },
  "app_url_cordova": "https://uluana.art",
  "assets_base_cordova": ".",
  "google_client_id": "638662643180-ci2halq1kaq0qfjfq7mhgl04kv95qe7j.apps.googleusercontent.com"
}
```

## Fonctionnalités implémentées cette session

### Infrastructure
- Séparation front/back en deux repos distincts
- VirtualHosts WAMP (uluana.local / uluana-server.local)
- Déploiement CI/CD via crons o2switch
- `migrate.php` idempotent avec tag `#dev-only`
- `version.json` généré à chaque déploiement → affiché dans barre dev
- `import.php` (reset complet MANUEL : catalogue + users/actions/suivis/rappels) lit `.env` via `_load_env()` — détecte `frontend/` automatiquement
- `import-catalogue.php` : sous-ensemble safe (catalogue/guides/détails/exemples/quiz uniquement, jamais users/actions/suivis/rappels) — appelé automatiquement par `deploy-staging.sh`/`deploy-prod.sh` après `migrate.php`, à chaque déploiement
- Migration SQL `2026_06_26_004_seed_test_accounts.sql` (#dev-only)

### Auth & Session
- Magic link → `/developpement` (page actions)
- Google OAuth : popup via `renderButton`, erreur `origin_mismatch` à régler dans Google Cloud Console
- `syncSession()` au chargement sidebar — refresh depuis DB pour cohérence multi-appareils
- `window.homePage()` — redirige vers dashboard si connecté, landing sinon
- Redirection automatique landing↔dashboard selon état de connexion
- `devLogin()` via `POST /auth/dev-login` → vrai JWT pour comptes de test

### Dashboard
- Page `dashboard.html` + `dashboard.js` avec auto-init `page-loaded`
- Sections : salutation, objectif CA, actions en cours (cliquables), suivi semaine, artisans, vidéo, partenaires
- Chargement depuis API (actions) et dernière saisie suivi

### Sidebar
- Bouton "Mode debug" en haut du nav (dev/local uniquement, attend `__APP_CONFIG`)
- Popup connexion : Google + email+mdp + magic link ("Mot de passe oublié")
- `SidebarAuth` : openLogin, closeLogin, resetLogin, submitPassword, submitLogin, signInWithGoogle
- Zone connectée/non connectée dans le footer
- `updateSidebarUser()` exposée globalement

### Barre dev (dev-switcher)
- Masquée par défaut, chargée à la demande via `toggleDebug()`
- Select2 avec deux groupes : "Comptes de test" + "Comptes réels"
- Comptes chargés depuis `/api/users?test=1` et `/api/users?real=1`
- Layout 2 lignes : row1 (MODE DEV · compte · ✕ · version), row2 (select)
- `devLogout()` → vide localStorage + `homePage()`
- `DataManager.clearCache()` au changement de compte

### Catalogue & Actions
- 124 cartes en base après `import.php`
- `guide-implementation.js` lit depuis `DataManager.getCard()` (API) au lieu des JSON statiques
- Guard `_lastLoadedCardId` pour éviter le chargement x4
- Route `GET /api/users?test=1` et `GET /api/users?real=1` (dev only)
- Route `POST /auth/dev-login` (dev only)
- Regex backend accepte path avec ou sans `/api/`

### CRÉATION / MISE À JOUR D'UNE CARTE — règle absolue

1. Créer ou modifier le fichier JSON **complet** dans `data/cards/` — checklist, card_detail, sections, tutos, mesure, liens, premier_pas — **tout doit être dans le JSON**
2. Ajouter une entrée dans `data/cards/_index.json` sous `"pending"` :
   ```json
   { "id": "C-XXX", "file": "C-XXX_nom.json", "updated_at": "YYYY-MM-DD" }
   ```
3. `_autorun_pending_card_imports()` lit `_index.json` au prochain appel API, importe chaque carte listée via `_import_card()`, puis **vide automatiquement** les entrées traitées.

**Ne jamais** créer de migration SQL manuelle pour l'import d'une carte.
**Ne jamais** modifier le contenu d'une carte via SQL direct.
**Ne jamais** passer par l'ancien pipeline `import-catalogue.php` pour les nouvelles cartes.

Le fichier JSON est la source de vérité. `_index.json` est le déclencheur. La BDD est la cible.

### profil_artisan
- `init()` charge depuis `GET /api/users/{id}/profile`
- `_fillProfile()` remplit les champs avec vrais données ou "Exemple : ..."
- `_saveProfile()` → `PUT /api/users/{id}/profile`
- Bouton déconnexion visible uniquement sur son propre profil

### CSS
- `input[type=password]` ajouté au sélecteur global des champs
- Styles `qp-partner` restaurés dans `pages.css`
- Layout 2 colonnes → 1 colonne sur mobile (`responsive.css`)
- Styles dashboard `.db-action-item` dans `pages.css`
- Bouton connexion sidebar `.sb-login-btn` dans `components.css`
- Thème sombre Select2 dans `dev.css`

### Retour utilisateur — renseignements & documents par carte
- Colonne `actions.data` (JSON) : réponses structurées de l'artisan propres à chaque carte (ex. C-087 : couleurs, polices, style photo, aide reçue + montant)
- Colonne `card_guide.renseignements_json` : schéma des champs déclaré dans le guide de la carte (`data/card_guides/*.json` → clé `renseignements.fields`) — types supportés : `text`, `number`, `boolean`, `tags`, `color_list`, avec `show_if` pour l'affichage conditionnel
- Table `action_documents` (transversale à toutes les cartes) : photos + documents (PDF/ZIP) de référence — upload via `/api/upload` (`type=action_image` ou `action_document`, `card_id` requis), listés/supprimés via `api/action-documents.php`
- Rendu générique dans `guide-implementation.js` (`renderRenseignements()`, `renderDocuments()`) — pour ajouter ce système sur une autre carte (ex. "Contacter des partenaires" : type de partenaire, moyen de contact, script), il suffit d'ajouter un bloc `renseignements.fields` dans son guide JSON et de pousser sur `develop` : **aucun code à écrire**, le cron (`import-catalogue.php`) prend le relais
- Sauvegarde via `ActionsService.saveData()` → `PUT /api/my-actions` (crée l'action si elle n'existe pas encore)
- Pas encore fait : affichage de ces renseignements sur `profil_artisan`, file d'attente "bilan avec l'équipe", intégration au `journal`

### Pipeline PDF des fiches-guide (pipeline_cartes_pdf)
- Fichiers du pipeline committés dans `uluana_server/claude/pipeline_cartes_pdf/` : `SPEC_pipeline_cartes_pdf.md` (règles détaillées), `generate_card_pdf.py`, `template.html`, `card-guide.css`, `categories.json` — à lire en premier avant de traiter une carte
- Génère une fiche-guide PDF autonome par carte (Jinja2 + WeasyPrint, hors-ligne — o2switch ne fait pas tourner Python) à partir de l'export `detail`+`guide`+`examples`
- Deux champs d'enrichissement (`comment_faire_par_etape`, `exemples_concrets`) **n'existent pas dans les JSON sources** — contrairement à `renseignements`, cette donnée est éditée **directement en base**, indépendamment du cycle de TRUNCATE du catalogue
- Colonnes `card_guide.comment_faire_par_etape_json` / `exemples_concrets_json`, exposées dans `api/cards.php`
- Validation : je (Claude) rédige `claude/enrichissement/enrichissement_<card_id>.json`, le montre pour validation visuelle (PDF généré), puis le committe une fois approuvé — c'est ce commit qui fait office de validation, pas une étape manuelle sur le serveur
- Une fois le fichier dans le dépôt et poussé sur `develop`/`main` : `import-enrichissement.php --all` (lit tous les `claude/enrichissement/enrichissement_*.json`) est appelé **automatiquement par le cron**, juste après `import-catalogue.php`, dans `deploy-staging.sh` et `deploy-prod.sh` — aucune commande manuelle à lancer sur le serveur
- Suivi façon `migrate.php` : table `enrichissement_log` (card_id + hash de contenu) — chaque fichier n'est appliqué qu'une fois, rejoué uniquement s'il change ; `--force` pour forcer une réapplication
- `import.php`/`import-catalogue.php` sauvegardent les deux colonnes avant leur `TRUNCATE` de `card_guide` et les restaurent après, pour ne jamais les effacer
- Brouillon validé sur C-087 (`claude/enrichissement/enrichissement_C-087.json`) — pipeline testé bout en bout (PDF généré avec succès)
- Prochaine carte à traiter : à définir (ex. "Contacter des partenaires")

### Nouveau format mesure/checklist — cadences par métrique (SPEC_format_cartes_mesure.md, session 2026-07-06)
- 3 nouveaux champs par carte, dans `guide` (`data/card_guides/*.json`) : `objectif` (label + `metric_principale`), `checklist` (remplace l'affichage "Ce que je mets en place"), `mesure.questions` (remplace à terme `mesure.indicateurs`)
- **`checklist`** (`card_guide.checklist_json`) = mécanisme canonique de la checklist désormais — items `{id, label, detail?, requires_proof, printable_worksheet?, worksheet_section?}`, câblé dans `import.php`/`import-catalogue.php`/`api/cards.php` comme `renseignements_json`. **Ne pas confondre avec `renseignements.fields`**, qui reste utilisé mais uniquement pour la collecte de données qui n'a rien d'une checklist (couleurs, polices, montant reçu — cf. C-087). Si une carte a une checklist de type booléen dans `renseignements.fields`, elle doit être migrée vers `checklist` (fait pour C-003).
- `printable_worksheet: true` sur un item → fiche PDF terrain imprimable générée à la volée par `api/card_worksheet.php` (Dompdf — première dépendance Composer du projet, `vendor/` gitignored, `composer install` dans `deploy-staging.sh`/`deploy-prod.sh` après chaque pull backend, non bloquant si composer absent sur l'hébergement). `worksheet_section` détermine le bloc affiché (seuls `observation`/`bilan` existent côté code aujourd'hui, cf. `claude/pipeline_cartes_pdf/NOTE_generation_dynamique_worksheet.md`) — un nouveau `worksheet_section` demande du code, pas juste du contenu.
- **`mesure.questions`** (`card_guide_mesure_questions` : metric/label/type/options/unit/**cadence_jours**/depends_on_metric) : chaque question a sa propre cadence — une carte peut mélanger plusieurs cadences (ex. C-006 : 7j contacts, 30j rentabilité). `mesure.indicateurs` (legacy) reste lisible pour les cartes pas encore migrées, le front préfère `questions` dès qu'il existe.
- Échéances de relevé : table `action_impact_schedule` (une ligne par action+métrique) — remplace `actions.impact_next_at` (**colonne supprimée**). `api/impact.php` : GET renvoie `metrics[]` avec `due` par métrique (masque une question si elle dépend d'une réponse booléenne négative via `depends_on_metric`) ; POST accepte plusieurs relevés en une fois (`{ readings: [...] }`). Cron `send_reminders.php` regroupe par action (un seul email même si plusieurs métriques échues le même jour).
- État : 2/124 cartes ont `checklist` (C-003, C-006 — modèles) ; `objectif`/`mesure.questions` pas encore rédigés pour aucune carte
- **`claude/redaction_cartes/PROMPT_redaction_cartes.md`** : prompt autonome à coller dans Claude Chat (avec le contenu actuel de la carte) pour préparer `objectif`/`checklist`/`mesure.questions` — Mr Fauquant l'utilise en dehors de Claude Code, puis redonne le JSON produit à valider/écrire/committer ici

### Workflow pour traiter une carte de A à Z (nouvelle session)
Donner juste l'ID/titre de la carte (ex. "C-094" ou "Contacter des partenaires") suffit — tout le reste est dans les deux dépôts. Règle d'ordre stricte : **le contenu (texte) est toujours validé par Mr Fauquant avant toute génération de PDF** — le PDF n'est jamais l'outil de validation du fond, seulement un contrôle final de mise en forme sur un contenu déjà approuvé. Étapes :
1. Lire l'existant de la carte : `data/card_details/*.json` + `data/card_guides/*.json` (pas besoin d'accès à la base, tout est dans Git)
2. **Checklist + objectif + mesure** : si Mr Fauquant fournit déjà un JSON produit via `claude/redaction_cartes/PROMPT_redaction_cartes.md`, vérifier qu'il respecte le schéma (cf. section ci-dessus) avant de l'ajouter à `data/card_guides/*.json` — sinon, proposer `objectif`/`checklist`/`mesure.questions` adaptés à cette carte (voir C-003/C-006 comme modèles) **en texte**, attendre la validation. Ne migrer `renseignements.fields` vers `checklist` que s'il s'agit bien de booléens type checklist (pas de données structurées type C-087).
3. **Amélioration des étapes / enrichissement PDF** : lire `claude/pipeline_cartes_pdf/SPEC_pipeline_cartes_pdf.md`, rédiger `comment_faire_par_etape` + `exemples_concrets` **en texte**, attendre la validation du contenu avant d'écrire quoi que ce soit dans `claude/enrichissement/enrichissement_<card_id>.json` (jamais dans les fichiers sources)
4. Une fois le contenu (checklist/objectif/mesure + enrichissement) validé en texte : écrire les fichiers, construire l'export JSON de la carte (`detail`+`guide`+`examples`, cf. `_card_full_payload()` dans `api/cards.php`) et générer le PDF via `python3 claude/pipeline_cartes_pdf/generate_card_pdf.py export.json output.pdf <card_id> --enrich enrichissement.json` (installer `weasyprint`/`jinja2` si absent)
5. Montrer le PDF — uniquement pour vérifier la mise en forme (pas de nouvelle validation de fond à ce stade)
6. Si la mise en forme est bonne : committer les fichiers modifiés (`card_guides/*.json` + `claude/enrichissement/enrichissement_<card_id>.json`) sur `develop` — le cron fait le reste automatiquement (catalogue + enrichissement, sans écraser aucune donnée utilisateur)

## Landing pages thématiques — système de bake (session 2026-07-17)

**Règle impérative** : toute nouvelle landing page (marketing, campagne,
lead magnet...) doit être créée dans `landing_themes`, jamais comme une page
SPA à part (`elems/pages/...`) — sinon elle n'apparaît pas dans
`admin-landing-themes` et n'a pas les bénéfices du système (bake statique,
SEO propre). Erreur commise et corrigée le 2026-08-01 (landing "bilan
d'activité" d'abord construite en page SPA isolée, invisible dans l'admin) —
ne pas reproduire.

### Principe général
Des landing pages courtes et ciblées (une par thématique commerciale), servies en
HTML **100% statique**, sans jamais charger la SPA (`index.html`/`index.js`).

**Changement d'architecture (2026-08-02)** : le contenu de CHAQUE landing vit
désormais dans un fichier — `landing_pages/pages/{slug}.html` — et non plus
dans des champs BDD éditables via l'admin. La BDD ne stocke plus que le
résultat calculé (`html_content`, `html_baked`, `baked_at`) et le statut
(`actif`, `est_accueil`, `ordre`, `image_url`). **Modifier ces fichiers
directement, c'est ce qui prend le pas** — voir `landing_sync_all_pages_from_files()`
dans `_landing_bake.php` :
- Une landing déjà en BDD sans fichier correspondant → export automatique de
  son contenu actuel vers `landing_pages/pages/{slug}.html` au premier passage
  (rien n'est perdu — sauf "accueil", dont le point de départ est
  `elems/pages/landing/landing.html`)
- Ensuite, le fichier fait foi : s'il diffère de `raw_html` en BDD → écrasement
  + re-bake automatique
- Un fichier ajouté sans ligne BDD correspondante → nouvelle landing créée
  automatiquement (actif, pas Accueil) — **pas besoin de passer par l'admin
  pour créer une landing**, juste déposer le fichier
- L'admin (`admin-landing-themes`) ne permet plus d'éditer titre/accroche/
  problème/destination/CTA — seulement l'image (upload) et le statut
  (actif/inactif, Principale). Toute tentative d'édition de contenu doit
  passer par le fichier, pas par l'admin.

**Bug de conception corrigé le 2026-08-04** : `landing_sync_all_pages_from_files()`
EXPORTE le contenu d'une thématique déjà en BDD vers un fichier au premier
passage — mais cet export écrit directement sur le disque du **serveur**, sans
jamais faire de commit Git. Résultat : les 7 landings simples + "accueil"
existaient en fichier sur le serveur mais étaient invisibles dans le dépôt
(donc invisibles pour Claude). Récupérées le 2026-08-04 via un dump BDD
(`raw_html` de chaque ligne) fourni par Mr Fauquant, maintenant commitées dans
`landing_pages/pages/`. **Vigilance à l'avenir** : si un fichier semble
manquant dans `landing_pages/pages/` alors qu'une landing existe et fonctionne
en prod/dev, ne pas supposer qu'il n'existe pas — il est probablement resté
uniquement côté serveur. Demander le contenu de `raw_html` via un export
phpMyAdmin de `landing_themes` plutôt que de recréer le contenu de mémoire.

### Système de blocs unifié (2026-08-04)
Les 7 landings à structure standard (celles listées ci-dessus, hors "accueil"/
"accueil-simple"/"parcours-vente" qui sont 100% libres) ont chacune 5 blocs
identifiables par id dans leur fichier : `block-titre`, `block-photo`
(présent sur `.page-warp-illustration` OU `.page-hero-image`, selon lequel des
deux est utilisé), `block-aujourdhui`, `block-cequecachange`, `block-cta`. Ces
5 ids + les 2 sections partagées (`block-temoignages`, `block-formules`,
ajoutés sur le conteneur racine de chaque section) forment un système unique :
tous activables ET réordonnables ensemble via `blocks_config` (JSON en BDD),
piloté par `landing_blocks_visibility_css()` qui génère du CSS `order`/
`display:none` — s'appuie sur `.page-scroll` déjà en `display:flex;
flex-direction:column` (aucune section n'a besoin d'être physiquement déplacée
dans le HTML, l'ordre visuel est 100% piloté par CSS). Toute nouvelle landing
créée à la main (raw_html libre) n'a PAS ces ids par défaut — les 5 blocs
cœur n'apparaîtront pas dans "Sections à afficher" tant qu'ils n'y sont pas
ajoutés manuellement dans le fichier.

Le "bake" reste une photo HTML de ce contenu (fichier, pas BDD), régénérée à
chaque changement, jamais calculée à la volée pour le visiteur.

### Table `landing_themes` (uluana_server)
Contenu éditable (source de vérité) :
- `slug` — identifiant, utilisé dans l'URL `/landing/{slug}`
- `titre`, `accroche`, `probleme`, `destination` — texte (structure "sell the
  destination" : accroche → problème → ce que ça change)
- `cta_label`, `cta_page` — libellé du bouton + page cible de l'app (ex. `quiz-profil`)
- `image_url` — image propre à la thématique (upload admin), remplace l'illustration
  warp générique si définie
- `raw_html` — contenu HTML complet importé tel quel, prioritaire sur tous les
  champs ci-dessus (utilisé pour la landing "Accueil" importée depuis
  `elems/pages/landing/landing.html`)
- `actif`, `ordre` — visibilité et tri
- `est_accueil` — une seule thématique peut être `1` à la fois ; celle-ci est
  servie à la racine du site (`/`) et sur `/landing` (sans slug)

Résultat du bake (généré, jamais édité à la main) :
- `html_baked` — nom du fichier archivé (`{slug}-{hash8}-{YYYYMMDD_HHMMSS}.html`)
- `html_content` — le document HTML complet déjà généré
- `baked_at` — date du dernier bake

### Fichiers "communs" (front, `landing_pages/`)
Maintenus indépendamment du bundle principal de l'app, **lus et embarqués au
moment du bake** (modifier ces fichiers n'affecte pas les pages déjà baked —
il faut re-baker, ce qui arrive automatiquement, voir plus bas) :
- `landing_pages/landings.css` — styles autonomes (aucune dépendance à `main.css`)
- `landing_pages/landings.js` — bouton retour flottant (`?from=`), préchargement
  en tâche de fond de l'app (`index.html`/`index.js`/`main.css`), réimplémentation
  minimale de `load_page()` (vraie navigation `/{page}`, aucune dépendance à
  l'app), listener de clic global sur `[load_page]` (pour compatibilité avec le
  contenu importé style ancien), shims de compatibilité (`homePage`,
  `update_menus`, `toggleDebug`, `DataManager.url`, `SidebarAuth.openLogin` en repli)
- `landing_pages/landings.html` — **template complet** de la page, avec deux
  types de jetons :
  - `###corps_landing###` → contenu spécifique de la thématique (généré depuis
    les champs BDD, ou `raw_html` si défini)
  - `###section:nom###` → code RÉEL d'une section de l'app (`elems/sections/nom/`),
    résolu en cascade (sections imbriquées incluses) — actuellement utilisé pour
    `menu_top` (vrai menu du haut), `sidebar_v2` (pour son popup de connexion
    réel `#sb-login-overlay` — la nav elle-même est masquée en CSS,
    `#sidebar-menu { display:none }`), `footer-sombre` (nouvelle section, fond
    sombre `var(--text)`, texte `var(--bg)`)
- `landing_pages/landings_baked/` — sortie générée directement par le back-end
  sur le serveur (jamais commitée, gitignored) :
  - `{slug}.html` — servi par `/landing/{slug}`
  - `_accueil.html` — nom fixe, servi à la racine du site et sur `/landing`
    (sans slug) si une thématique est désignée `est_accueil=1`

### Pipeline de bake (`uluana_server/api/_landing_bake.php`)
- `landing_render_body($theme)` — génère le HTML du contenu (ou renvoie
  `raw_html` tel quel si défini), toujours précédé du filtre SVG warp
  (`landing_warp_filter_defs()`, toujours émis, utilisé par l'illustration ET
  le CTA)
- `landing_render_document($theme, $body, $env)` — assemble le document complet :
  lit `landings.html`, remplace `###corps_landing###`, résout les jetons
  `###section:xxx###` via `landing_inject_sections()`, charge le CSS partagé de
  l'app (`variables/background/layout/components/cards/forms/pages/responsive/
  utilities` + `theme-2026.css` — nécessaire pour `.nav-cta-login` et la media
  query mobile qui masque les dropdowns) **uniquement si une section est
  utilisée**, embarque le client_id Google (`config.json`) + le script GSI
- `landing_fetch_section($name, $projectRoot, $ancestorPath, ...)` — lit
  `elems/sections/{name}/{name}.html` (+ `.css`/`.js` séparés), extrait les
  `<style>`/`<script>` inline (XPath, **pas `getElementById`** — peu fiable sans
  DTD, cause d'un bug déjà rencontré), résout les sections imbriquées
  (`[load_section]`) récursivement. `$ancestorPath` détecte une vraie boucle
  (section qui s'inclut elle-même) sans bloquer une même section réutilisée à
  plusieurs endroits de la page (ex. `garantie-lancement`, utilisée 2x dans la
  landing Accueil) ; `$emitted` évite de dupliquer le CSS/JS d'une section
  réutilisée
- `landing_bake_theme($pdo, $theme, $env)` — orchestre le tout, écrit
  l'archive locale (back) + la copie servie (front, `landings_baked/{slug}.html`),
  + `_accueil.html` si `est_accueil`, met à jour `html_content`/`html_baked`/`baked_at`
- `landing_set_accueil($pdo, $slug, $value, $env)` — bascule Principale (une
  seule à la fois), re-bake, supprime `_accueil.html` si plus aucune désignée
- `landing_sync_all_pages_from_files($pdo, $env)` — **mécanisme unique** (remplace
  les anciennes fonctions `landing_seed_accueil_theme`/`landing_sync_accueil_theme`
  spécifiques à une seule thématique) : pour CHAQUE fichier de
  `landing_pages/pages/*.html`, synchronise vers la BDD (fichier fait foi,
  crée la ligne si absente) ; exporte le contenu actuel vers un fichier pour
  toute thématique en BDD qui n'en a pas encore (bootstrap sans perte, "accueil"
  démarrant depuis `elems/pages/landing/landing.html`)
- `landing_rebake_if_template_changed($pdo, $env)` — hash (mtime) de
  `landings.html/css/js` + toutes les sections référencées (cascade) ; si un
  seul fichier a changé depuis le dernier bake connu (table `landing_bake_meta`),
  re-bake **toutes** les landings automatiquement (mécanisme séparé de
  `landing_sync_all_pages_from_files`, qui suit le contenu par thématique)

### Déclencheurs de bake (aucune action manuelle requise)
1. `PUT /api/admin-landing-themes/{slug}` (image/statut uniquement désormais) → re-bake immédiat
2. `migrate.php` (après chaque `git pull`, cron) et chargement de la page admin
   (`GET /api/admin-landing-themes`) → tous deux appellent
   `landing_sync_all_pages_from_files()` (contenu par fichier) et
   `landing_rebake_if_template_changed()` (template/sections partagés)
3. Bouton "Re-baker tout" sur `admin-landing-themes` → force le re-bake de
   toutes les landings sans condition (`POST /api/admin-landing-themes/rebake-all`)
4. Pour forcer un re-bake global depuis une migration SQL :
   `UPDATE landing_themes SET html_content = NULL;`

### Perf — liste admin allégée (2026-07-18)
`GET /api/admin-landing-themes` faisait un `SELECT *` (donc `html_content` +
`raw_html`, potentiellement énormes — toute une page + le CSS partagé de l'app
pour la landing Accueil) et relançait `landing_rebake_if_template_changed()` +
`landing_seed_accueil_theme()` à **chaque chargement de la page**, ce qui
pouvait déclencher un re-bake complet en plein milieu d'une requête de lecture
→ lenteurs, parfois timeout. Corrigé : la liste ne sélectionne plus que les
colonnes affichées, et ces deux vérifications ne tournent plus que dans
`migrate.php` (cron). Si un nouveau symptôme de lenteur apparaît sur cette
page, vérifier en premier qu'aucune requête `SELECT *`/vérification lourde n'a
été réintroduite dans le chemin de lecture.

### Routing (.htaccess front)
```
# Accueil désignée (racine + /landing sans slug)
RewriteCond %{DOCUMENT_ROOT}/landing_pages/landings_baked/_accueil.html -f
RewriteRule ^(?:landing/?)?$ landing_pages/landings_baked/_accueil.html [L]

# Landing thématique par slug
RewriteCond %{DOCUMENT_ROOT}/landing_pages/landings_baked/$1.html -f
RewriteRule ^landing/([a-z0-9-]+)/?$ landing_pages/landings_baked/$1.html [L,QSA]
```
Tant qu'aucun fichier baked n'existe, ces règles ne matchent pas et on retombe
sur la SPA normalement (rollout non-cassant).

### Landing SPA interne (`elems/pages/landing/landing.js`) — synchro avec l'Accueil
La navigation *interne* à l'app (`load_page('landing')`, ex. déconnexion,
`homePage()`) passe par le composant SPA classique
(`elems/pages/landing/landing.html`/`.js`), **pas** par le `.htaccess` — donc
pas par le fichier baked. Pour qu'elle affiche quand même la thématique
désignée Accueil (et pas un contenu statique figé) :
- `GET /api/landing-themes/accueil-actuel` (public, `landing-themes.php`) —
  renvoie uniquement le **fragment** de contenu (`landing_render_body()`, pas
  le document complet) de la thématique `est_accueil = 1` : la SPA a déjà son
  vrai menu/sidebar, pas besoin des sections (`menu_top`/`sidebar_v2`/...)
- `Landing.loadAccueilContent()` (exposée sur `window.Landing`) appelle cet
  endpoint et remplace le contenu de `#landing-default-scroll`. Repli
  silencieux sur le HTML statique existant si l'appel échoue
- **Cas spécial ciblé dans `load_page()` (`index.js`)** : pour éviter un flash
  de l'ancien contenu statique avant remplacement, `load_page` attend
  `Landing.loadAccueilContent()` **avant** `revealPage(page)` quand
  `page === 'landing'` — seule cette page a ce traitement particulier, le
  routeur reste inchangé pour toutes les autres
- Limite connue : si la page landing est déjà en cache DOM (`target.dataset.loaded`),
  une re-navigation vers `/landing` sans recharger ne repasse pas par ce
  chemin — cas rare (il faudrait changer la Principale en admin puis revenir
  sur une landing déjà visitée dans la même session), non traité

### Config serveur requise
`FRONT_DIR` dans `.env`/`.env.dev` (back) = chemin absolu de la **racine du
projet front** déployé sur le serveur (celui qui contient `landing_pages/` et
`elems/`) — PAS un sous-dossier. Sans ça, le bake fonctionne toujours en BDD
mais aucun fichier n'est écrit côté front (log `error_log` explicite +
bandeau d'alerte rouge dans la page admin si détecté).

### Admin (`admin-landing-themes` — page front + `admin-landing-themes.php`)
- Liste toutes les thématiques (actives + inactives), titre/slug empilés,
  aperçu image (ou pot décoratif par défaut si pas d'image)
- Boutons par ligne : **Photo** (upload, `POST /api/admin-landing-upload`),
  **Visualiser** (lien direct `/landing/{slug}`), **Activer/Désactiver**,
  **Principale** (vert si Accueil actuelle, gris+barré sinon)
- Accès via le menu Administration de la sidebar

### Limitations connues
- `menu_top` a été conçu pour cohabiter avec la sidebar (largeurs calées sur
  216px) — sur une landing sans sidebar, l'affichage peut légèrement différer
  de l'app
- Un debug temporaire (`alert()` sur les appels `/api/auth/`) a pu être laissé
  dans `landing_pages/landings.js` pour diagnostiquer le popup de connexion —
  **à vérifier et retirer** si encore présent
- Pas de vraie hydratation SPA : le CTA fait une navigation classique
  (`load_page()` autonome = `window.location.href`), pas une transition
  client-side — volontaire, pour rester 100% indépendant

## Formulaires de candidature en étapes + acompte (session 2026-07-29)
- **Restructuration en 4 étapes** (`candidature-premium` et
  `candidature-essentiel`, même structure `.cand-panel[data-step]`) :
  1. Identité (nom, email, marque, métier — préremplis depuis le profil)
  2. Photos + liens portfolio
  3. Situation (besoin, CA, objectif, déjà essayé)
  4. Acompte de 10% (paiement + envoi)
  Navigation Suivant/Précédent (`nextStep()`/`prevStep()`), validation par
  étape avant de pouvoir avancer. CSS partagé dans `css/split/pages.css`
  (`.cand-progress`, `.cand-panel`, `.cand-retract`, `.cand-photo-grid`,
  `.cand-links-list`, `.cand-nav`) — ne pas dupliquer dans les fichiers de
  page.
- **Système à choix rétractable** (`.cand-retract`, `selectRetract()`) :
  sélectionner une option replie les autres (`.collapsed`) ; recliquer sur
  l'option déjà sélectionnée les redévoile. Utilisé pour "besoin prioritaire"
  et "CA mensuel" sur les deux formulaires.
- **Photos/liens** : préremplis depuis `profile.gallery`/`profile.
  portfolio_links` si déjà présents (auquel cas rien n'est renvoyé à la
  soumission — le profil fait foi) ; sinon collectés depuis le formulaire et
  envoyés dans le payload (`photos`, `links`) pour que le backend les
  sauvegarde dans le profil (voir `sfauquant/uluana_server/CLAUDE.md`).
- **Bug corrigé** : l'upload de photos ne fonctionnait pas sur ces pages —
  `/api/upload` exigeait une authentification que le prospect n'a pas encore
  à ce stade (voir fix backend, `type='candidature'`).
- **Soumission** : `POST /api/candidatures` crée la candidature (acompte déjà
  calculé) et connecte automatiquement le prospect (cookie + token dans la
  réponse, `SessionManager.set()`) — sans quoi le paiement qui suit
  immédiatement échouerait en 401. Puis `PaymentPopup.open()` avec
  `productType='candidature'` ; au succès, écran de confirmation (+ lien
  Zcal pour l'entretien côté Premium).
- **Timeline "Ce qui vous attend"** : identique sur les deux formules
  (Candidature → Entretien 30 min → Questions préparatoires → Démarrage) —
  l'essentiel passe désormais aussi par un entretien.

## Popup de paiement, bannière candidature, plafond parcours (session 2026-07-28)
- **Nouvelle section `elems/sections/payment-popup/`** : grande popup de
  paiement mobile-first (remplace l'ancienne popup 420px construite à la
  volée dans `payment-service.js`). API : `PaymentPopup.open({label,
  description, amountCents, currency, productType, productId, userEmail,
  onSuccess, onError})` / `.close()`. Deux états : formulaire (Card Element
  Stripe) → succès (confirmation + rappel "facture envoyée à {email}, en
  copie contact@uluana.art", lit `invoice_number`/`invoice_email` renvoyés
  par `/api/payment/confirm`). Chargée en `autoload` dans `index.html` (comme
  `footer-sombre`), disponible partout sans qu'aucune page n'ait à
  l'inclure explicitement.
- **`js/payment-service.js` allégé** : ne garde que `getStripe()` (singleton
  Stripe.js). Toute la construction de DOM/formulaire a été retirée — c'est
  maintenant le rôle exclusif de `payment-popup`.
- **Appelants mis à jour** : `parcours.js` (`openPurchase()`) et
  `candidature-paiement.js` (`pay()`) utilisent `PaymentPopup.open(...)` au
  lieu de l'ancien `PaymentService.charge(...)`. `formules.js` n'est **pas**
  concerné — il utilise toujours `PaymentManager`/Stripe Checkout, un
  système différent et actuellement orphelin (voir session Stripe du
  2026-07-28 plus haut dans l'historique de conversation — pas encore
  documenté ici, à traiter séparément).
- **Nouvelle section `elems/sections/candidature-reminder/`** : bannière
  flottante persistante en haut de page (`autoload`, présente partout),
  affichée si l'utilisateur connecté a une candidature `validee` mais
  `payment_status != 'paye'` (`GET /api/candidatures/me`). CTA "Régler
  l'inscription" → ferme la bannière et ouvre `PaymentPopup` directement
  (mêmes infos que `candidature-paiement`) ; échec de paiement → la bannière
  réapparaît. Masquée sur la page `candidature-paiement` elle-même (évite le
  doublon). Vérifie l'état à chaque `page-loaded` (coût négligeable, l'API
  ne renvoie rien s'il n'y a rien en attente).
- **Plafond parcours essentiel** : voir détail complet côté backend
  (`sfauquant/uluana_server/CLAUDE.md`). Côté front, `parcours.js` consomme
  le nouveau flag `has_other_active` renvoyé par `GET /api/parcours/{id}`
  pour afficher le bon prix/bouton, et n'affiche plus un état "actif"
  optimiste pour un essentiel avant la réponse serveur (seul premium reste
  optimiste, vraiment illimité).
- **Facturation** : voir détail complet côté backend. Le front n'a rien à
  générer — il affiche simplement la confirmation renvoyée par l'API dans
  l'état "succès" de `payment-popup`.

## Audit i18n (session 2026-07-28)
- Script d'audit ajouté : `claude/scripts/check_i18n_coverage.py` (voir
  détail dans `claude/I18N.md`, section "Garde-fou anti-régression").
- 9 libellés/placeholders corrigés (email, quiz-profil, `garantie-lancement`
  entièrement migrée).
- Deux chantiers identifiés mais non traités, à planifier avec Mr Fauquant :
  `guide-implementation` (section entière jamais traduite, gros volume,
  flux très utilisé) et `paywall-popup` (code mort — JS jamais écrit,
  section jamais chargée nulle part — à supprimer ou reprendre).

## Contrat de collaboration entre artisans (session 2026-07-28)
- Déclenchement : depuis `collaboration.html`, l'auteur d'une demande clique
  "Accepter" sur une réponse reçue (`_loadDetailReplies`) → `Collaboration.acceptReply(replyId)`
  → `POST /api/collaboration-matches` (idempotent, un seul match par `reply_id`)
  → redirection vers `contrat-collaboration?match_id=X`.
- Nouvelles tables (`sql/2026_07_28_137_collaboration_contract.sql`) :
  `collaboration_matches` (lien demande/réponse/deux artisans),
  `collaboration_contract_answers` (une ligne par artisan par match, JSON libre),
  `collaboration_contracts` (texte final verrouillé une fois généré).
- `api/collaboration-matches.php` : POST (accepter une réponse → créer le
  match, seul l'auteur de la demande peut le faire), GET `?mine=1` (mes
  matches, requester ou partner).
- `api/collaboration-contract.php` : GET `?match_id=X` (mes réponses +
  réponses partenaire **révélées seulement si j'ai moi-même déjà répondu**
  — vote à l'aveugle pour éviter le mimétisme — + désaccords détectés +
  texte du contrat si finalisé), PUT (soumet/remplace mes réponses ; si les
  deux ont répondu et qu'aucun désaccord bloquant n'est détecté, génère et
  verrouille automatiquement le contrat via `_generateContractText()`).
- Détection de désaccord (`_detectFlags()`) : somme charge_pct / revenue_pct
  hors [95,105] → conflit ; réponses différentes sur facturation/
  interlocuteur/priorite_futur/propriete/portfolio/alea/duree → conflit ;
  assurance RC pro non confirmée par l'un des deux → alerte de type `risk`
  (pas un désaccord entre eux, mais un manque partagé).
- Contrat généré (`_generateContractText`) : mentionne explicitement
  qu'Uluana n'est pas partie au contrat et n'intervient ni dans son
  exécution ni comme garant — uniquement la mise en relation/formalisation.
- Front : nouvelle page `elems/pages/contrat-collaboration/` (formulaire des
  16 questions organisées en 6 groupes, bandeau d'état selon
  `both_submitted`/`flags`/`contract_text`, formulaire réactivable pour
  corriger en cas de désaccord, verrouillé une fois finalisé). Entrée
  ajoutée dans "Mes collaborations" (`renderMyCollabs()` dans
  `collaboration.js`) listant les contrats en cours/finalisés.
- i18n : chrome de `contrat-collaboration` intégralement traduit FR/EN dès
  sa création (labels, options, bandeaux d'état) — contrairement à
  `mentions-legales`, il ne s'agit pas de texte juridique figé mais de
  libellés d'interface classiques. Seul le texte du contrat généré
  (`contract_text`, stocké côté back) reste en français.
- Entrée `site_updates` ajoutée (`2026_07_28_138`).
- Pas encore fait : notification au partenaire quand un match est créé ou
  quand l'autre partie a répondu (aujourd'hui il doit retourner sur
  "Mes collaborations" pour le découvrir) ; export PDF du contrat final ;
  la structure des 16 questions reste ouverte à évolution selon retours.

## Pages légales (session 2026-07-28)
- Nouvelle page `mentions-legales` (`elems/pages/mentions-legales/`) : hub
  unique avec navigation par ancre regroupant Mentions légales, CGU, CGV,
  Politique de confidentialité et Politique de cookies — pas de JS requis
  (page statique), style inline comme la convention actuelle (voir
  `set-password.html`).
- Lien ajouté dans `footer-sombre.html` (visible partout, app + landing) :
  `load_page('mentions-legales','main_page')`.
- Chrome (nav des 5 onglets) traduit FR/EN ; corps des textes légaux
  volontairement français uniquement — voir décision documentée dans
  `claude/I18N.md`.
- À faire par Mr Fauquant avant publication réelle : voir section
  **TODO / En suspens** en haut de ce fichier (entrées du 2026-07-28).

## Internationalisation (i18n) — FR/EN (session 2026-07-24)

Changement de langue depuis les paramètres personnels (`mon-compte`, onglet
"Paramètres Uluana"). **Lire `claude/I18N.md` avant toute modification** —
guide complet : comment traduire une page, ajouter/supprimer une clé, ajouter
une langue, routing `/en/...`, ce qui est hors scope (contenu des cartes,
emails, PDF).

- Moteur : i18next + i18next-http-backend (CDN, pas de build step) —
  `js/i18n.js`. Dictionnaires : `locales/{fr,en}/{namespace}.json`, un
  namespace par page/section.
- Convention : `data-i18n="cle"` dans le HTML, texte français gardé comme
  contenu par défaut. Repli automatique sur le français si une clé/langue
  manque — jamais bloquant.
- URL : français = sans préfixe (inchangé), anglais = `/en/...`. Détection :
  préfixe URL > `localStorage.uluana_lang` > langue navigateur > français.
- Sélecteur de langue : `Langue.set()` dans `mon-compte.js` — persiste en
  base (`PUT /api/users/{id}/account`, colonne `users.lang`), puis
  rechargement complet vers l'URL équivalente dans la nouvelle langue.
- Landing pages (bake statique) : plomberie technique en place
  (`/en/landing/{slug}`, colonnes `landing_themes.*_en`), contenu anglais pas
  rédigé (repli français) — voir doc backend `uluana_server/claude/I18N.md`.
- État : **toutes les pages de l'app sont traduites FR/EN** (~40 namespaces,
  un par page/section — liste complète dans `claude/I18N.md`), à l'exception
  volontaire de `dev-xp-toast` (page de démo développeur, jamais vue par un
  utilisateur final). Contenu hors scope resté en français partout où c'est
  applicable : catalogue de cartes, quiz, données de parcours/audit, contenu
  partenaires/collaboration/analyse démo — voir "Hors scope" dans
  `claude/I18N.md`. Le chantier page-par-page est donc terminé ; toute
  nouvelle page doit être migrée dès sa création en suivant la procédure de
  `claude/I18N.md`.

1. **profil_artisan** affiche "Sophie Arnaud" au lieu des infos du compte connecté → init() corrigé mais pas encore testé
2. **Google OAuth** — `origin_mismatch` sur mobile → ajouter `https://dev.uluana.art` dans Google Cloud Console → Identifiants → Origines JavaScript autorisées
3. **Email bienvenue** — SMTP fonctionne en test direct mais pas encore confirmé depuis le flux inscription

## Lexique — toute nouvelle fonctionnalité ou terme métier
Lire `claude/LEXIQUE.md` avant de commencer une tâche un peu conséquente —
il recense les termes métier, les décisions déjà prises et les pièges
connus, pour qu'une nouvelle session (Claude ou un autre LLM) reste
cohérente avec ce qui a été construit, sans tout redéduire du code ni
recontredire silencieusement un choix déjà fait. **À chaque nouvelle
fonctionnalité ou nouveau terme métier introduit, ajouter une entrée** :
nom, une phrase de description, fichiers clés, et le piège/la décision à
connaître s'il y en a une. Une entrée courte suffit — l'objectif est un
point d'accès rapide, pas une documentation exhaustive.

## Traduction (i18n) — toute nouvelle interface
Le site est entièrement bilingue FR/EN (voir `claude/I18N.md`, lecture
obligatoire avant de toucher à l'affichage). **Toute page ou section créée à
partir de maintenant doit être traduite dès sa création** — jamais de texte
français en dur dans le HTML/JS hors du système `data-i18n` : suivre la
procédure "Ajouter une traduction sur une page qui n'a pas encore migré"
dans `I18N.md` (créer `locales/fr/{page}.json` + `locales/en/{page}.json`,
`data-i18n="cle"` dans le HTML en gardant le texte français par défaut,
`I18n.t('cle', {ns:'namespace'})` pour le texte généré en JS). Ça inclut les
petits ajouts sur une page déjà migrée (ex: un nouveau bloc dans
`mon-compte`) — ne pas oublier d'ajouter la clé dans les deux JSON, pas
seulement dans le HTML. Erreur commise le 26/07 : plusieurs pages neuves
(`services-uluana`, `demande-collaboration`, `demandes-collaborations`) et
un ajout sur `mon-compte` livrés sans traduction — corrigé après coup, à ne
pas reproduire.

## Historique des nouveautés du site (site_updates)
À chaque changement visible côté client (nouvelle fonctionnalité, changement de
formule/pricing, refonte d'une page, etc.), ajouter une entrée dans la table
`site_updates` (back, migration SQL `INSERT INTO site_updates (title, description,
published_at) VALUES (...)`, ou `POST /api/site-updates`). Cette table alimente
la section "Nouveautés du site" dans Mon compte > Paramètres Uluana, **visible par
les clients** — donc rédiger en langage client (bénéfice pour l'utilisateur), pas
en langage technique/dev. Ne pas oublier ce réflexe en fin de session sur toute
demande qui change concrètement l'expérience du client.

## Prochains sujets (backlog)
- Parcours artisan complet : invitation par lien, onboarding, objectif CA visible partout
- Page définir/modifier mot de passe après magic link
- Statut "déjà fait" sur les actions
- Profil artisan : infos réelles affichées + édition fonctionnelle
- Dashboard : objectif CA saisie + affichage persistant
- Suivi artisan : saisie hebdomadaire (ventes, clients vus, CA estimé, type clientèle)
- Parcours thématiques
- Notifications / rappels
- Préparation Cordova (config.json déjà prêt)
- Note mémo : à la première inscription mail, créer compte prospect avec 3 actions → IMPLÉMENTÉ

## Commandes utiles o2switch
```bash
# Forcer MAJ depuis GitHub
git -C ~/develop/uluana/frontend reset --hard origin/develop
git -C ~/develop/uluana/backend reset --hard origin/develop

# Lancer les migrations manuellement
php ~/develop/uluana/backend/deploy/migrate.php

# Import catalogue — auto à chaque déploiement, relançable manuellement sans risque
php ~/develop/uluana/backend/import-catalogue.php

# Reset complet MANUEL uniquement (catalogue + users/actions/suivis/rappels — écrase les données réelles)
# https://dev-api.uluana.art/import.php

# Logs
tail -f ~/logs/dev.log
```

## Règles critiques — erreurs rencontrées (ne pas reproduire)

### URLs API dans le frontend
`DataManager.url(path)` utilise `api_base` = `https://dev-api.uluana.art` (ou prod).
Le serveur API **est déjà** le serveur d'API — pas de préfixe `/api/` à ajouter.

✅ Correct : `DataManager.url('/creator-pages')`
❌ Faux : `DataManager.url('/api/creator-pages')` → donne `https://dev-api.uluana.art/api/creator-pages` (double `/api/`)

**Règle : jamais de `/api/` dans les paths passés à `DataManager.url()`.**

### Foreign keys dans les migrations SQL
Les tables avec `FOREIGN KEY ... REFERENCES users(id)` échouent sur o2switch avec errno 150.
**Règle : ne jamais mettre de FOREIGN KEY dans les migrations. Toujours `CREATE TABLE IF NOT EXISTS` sans FK.**

### require_role() — rôles membres
Les rôles membres sont `client_essentiel` et `client_premium`, pas `membre`.
`require_role('membre')` ne correspond à aucun rôle réel et bloque tous les membres.

✅ Correct : `require_role('admin', 'responsable', 'client_essentiel', 'client_premium')`
❌ Faux : `require_role('admin', 'responsable', 'membre')`

### Routes PHP — ordre des conditions
Une route publique `if ($slug && !$action)` intercepte TOUS les slugs y compris `me`, `reorder`, etc.
**Règle : toujours placer les routes nommées spéciales (`me`, `reorder`, `stats`...) AVANT la route générique par slug, et exclure explicitement : `if ($slug && $slug !== 'me' && !$action)`.**

### PDO — pattern obligatoire
`db()` retourne un `PDO` brut. `PDO::query()` n'accepte PAS de tableau de paramètres.

✅ Correct :
```php
$pdo = db();
$stmt = $pdo->prepare("SELECT * FROM table WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch();
```
❌ Faux : `db()->query("SELECT * FROM table WHERE id = ?", [$id])->fetch()`
