2 Commits

Author SHA1 Message Date
Jules Neny
a073b14a81 fix(taff): patch types - 'commission' dans CoutEntree + axes nullable pour AO publics
- CoutEntree : ajout 'commission' (cas hemea, modeles commission %)
- ScoringTaff : remuneration/pratiques/ecologie sont AxeScore | null
  Pour les plateformes appel-offre-public, scoring simplifie 2 axes
  (transparence + matching uniquement, decision F du MP TAFF V1)

Pre-dispatch T2 - patch identifie en tour 2 critique.
2026-05-06 17:31:52 +02:00
Jules Neny
a05db54d7a feat(taff): scaffold page + types PlateformeTaff V1
- pages/trouver-du-taf.vue : squelette placeholder (branché par T4)
- types/plateforme-taff.ts : typage complet (PlateformeTaff, ScoringTaff, helpers)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:53:22 +02:00
33 changed files with 567 additions and 5366 deletions

View File

@@ -11,48 +11,6 @@ Journal technique de la V2. Décisions, anomalies, points bloquants, TODOs.
--- ---
## 2026-04-29 — Cascade Onglet 1 : Pratiques régénératives (P1 → P5b)
**Commit deploy :** `e80b226` (feat/aep-pratiques-regeneratives, 10 commits depuis main)
**Exécutant :** Sonnet (agent autonome P1-P5b)
### Chantier P1 → P5b (résumé)
Création complète de l'onglet "Pratiques régénératives" sur `aep.trans-former.fr` :
- **P1** : scaffold types + API statique `GET /api/pratiques` (52 fiches JSON `public/data/pratiques-regeneratives.json`)
- **P2** : page `/pratiques-regeneratives` — carte Leaflet Europe + accordéon DOM-TOM, sidebar filtres (type, pays, matériaux), composants `PratiqueCard.vue` + `PratiqueModal.vue`
- **P3** : ajout onglet "Pratiques régé" dans le header nav desktop + hamburger mobile
- **P4** : page `/proposer-pratique` — formulaire contribution avec Zod, endpoint `POST /api/submit-pratique` avec rate limit, `public/data/pratiques-pending.json`
- **P5a** : build local validé (3.04 MB, APIs 200, 500 SSR = bug Windows/Node 24 préexistant non-bloquant)
- **P5b** : deploy prod + smoke test (3/3 endpoints 200, SSR title OK, JSON 52 fiches)
### Deploy
- Méthode : `tar .output/ | ssh vps-hetzner "cd /opt/aep && tar -xzf -"` + `systemctl restart aep`
- Env diff local vs VPS : VPS a des vars supplémentaires (MISTRAL, NOCODB worker, RESEND) — additionnel non-conflictuel, pas d'impact
- Note `deploy.sh` : le script a un BOM UTF-8 (ligne 1 `\xEF\xBB\xBF#!/bin/bash`) qui cause un exit 1 sur le `read -p` quand stdin est un pipe. Contournement : exécution manuelle des étapes. A corriger en V3.
### Smoke test prod (2026-04-29 01:38 UTC)
| Endpoint | HTTP | Note |
|---|---|---|
| GET /pratiques-regeneratives | 200 | SSR OK, titre trouvé (2 occurrences) |
| GET /proposer-pratique | 200 | SSR OK |
| GET /api/pratiques | 200 | JSON valid, 52 fiches |
### Ce qui reste à valider (Jules, E2E BrowserMCP)
- Markers Leaflet visibles + cliquables (Europe + DOM-TOM)
- Sidebar filtres fonctionnels (type, pays, matériaux)
- Modal fiche + bouton retour preservant filtres
- Formulaire `/proposer-pratique` : submit + message succès
- Comportement mobile 375×667 (sheet bas, swipe filtres, fiche pleine page)
Prompt E2E disponible : `aep-communaute-build/PROMPT-BROWSERMCP-E2E.md`
---
## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys ## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen **Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen

View File

@@ -1,45 +0,0 @@
# INDEX — Cascade Onglet 1 : Pratiques régénératives
> Branche : `feat/aep-pratiques-regeneratives`
> Démarré : 2026-04-28
> Dernière mise à jour : 2026-04-29
## Statut global
```
P1 Types + API statique [x] done — commit a70c9e0
P2 Page /pratiques-regeneratives [x] done — commit a70c9e0
P3 Onglet nav + hamburger [x] done — commit 5fabcde
P4 /proposer-pratique + POST API [x] done — commits 83d4bd1 d10586c f25a7d3
P5a Build local + smoke test [x] done — commit e80b226 (recap P5a-RECAP.md)
P5b Deploy prod + smoke test prod [x] done — deploy 2026-04-29 01:38 UTC
JOURNAL-V2.md entrée 2026-04-29 [x] done
PROMPT-BROWSERMCP-E2E.md créé [x] done
Branche pushée sur Gitea [x] done — feat/aep-pratiques-regeneratives
Mise en ligne validée (E2E Jules) [ ] en attente — Jules sur autre PC
```
## Fichiers produits
| Fichier | Description |
|---------|-------------|
| `aep-communaute-build/P4-RECAP.md` | Récap chantier P4 form + API |
| `aep-communaute-build/P5a-RECAP.md` | Récap build local Windows |
| `aep-communaute-build/PROMPT-BROWSERMCP-E2E.md` | Prompt E2E pour Jules (autre PC) |
| `aep-communaute-build/INDEX.md` | Ce fichier |
## Smoke test prod (2026-04-29 01:38 UTC)
| Endpoint | HTTP | Detail |
|----------|------|--------|
| GET /pratiques-regeneratives | 200 | SSR OK, titre trouve (2 occurrences) |
| GET /proposer-pratique | 200 | SSR OK |
| GET /api/pratiques | 200 | JSON valid, 52 fiches |
## Action Jules
1. Copier `PROMPT-BROWSERMCP-E2E.md` sur le PC avec BrowserMCP + Brave
2. Lancer une session Claude Code, coller le prompt
3. Reporter les resultats dans `aep-communaute-build/E2E-RESULTS.md`
4. Cocher "Mise en ligne validee (E2E Jules)" ci-dessus
5. Merger `feat/aep-pratiques-regeneratives` dans `main`

View File

@@ -1,76 +0,0 @@
# P4 — Form contribution "Proposer une pratique" — Récap
**Date :** 2026-04-28
**Branche :** feat/aep-pratiques-regeneratives
**Commits :** 3 atomiques (83d4bd1, d10586c, f25a7d3)
---
## Fichiers créés / modifiés
| Fichier | Statut | Description |
|---------|--------|-------------|
| `pages/proposer-pratique.vue` | créé (833 lignes) | Formulaire complet clone adapté de contribuer.vue |
| `server/api/submit-pratique.post.ts` | créé (117 lignes) | Endpoint POST avec Zod + rate limit + append pending |
| `public/data/pratiques-pending.json` | créé (`[]`) | File de modération V1, nettoyée après tests |
| `components/PratiqueSidebar.vue` | modifié | CTA "+ Proposer une pratique" en bas de sidebar |
---
## Formulaire — champs implémentés
- **Nom** (obligatoire, 3-150c)
- **URL** (optionnel)
- **Description** (obligatoire, 50-500c + compteur live)
- **Critères régénératifs** (checkboxes 8 critères, min 3 / max 8, désactivation au-delà)
- **Type d'entité** (radio pill : 9 options depuis TYPES_ENTITE)
- **Pays** (dropdown groupé Europe 16 codes / DOM-TOM 11 codes / Autre texte libre conditionnel)
- **Ville** (optionnel)
- **Tags** (optionnel, input virgule-séparé → chips preview, max 6 × 30c)
- **Email** (optionnel)
---
## Endpoint serveur
- Validation Zod miroir du client (schéma identique)
- Rate limit JSON : 3 soumissions / IP hashée SHA-256 / jour (action `submit-pratique`)
- Lecture / écriture `public/data/pratiques-pending.json` (init `[]` si absent)
- Entrée : champs validés + `id: timestamp` + `submitted_at: ISO` + `moderation_status: 'pending'`
- Retour : `{ ok: true, trackingId: timestamp }`
- Commentaire modération V2 en haut du fichier
---
## Résultats tests
| Test | Résultat |
|------|----------|
| GET /proposer-pratique | 200 |
| POST valide | 200 + entrée pending.json |
| POST invalide (nom 2c, desc trop courte, criteres < 3) | 422 + fieldErrors structurés |
| Rate limit : 3ème soumission depuis même IP | 429 |
| pending.json après nettoyage | `[]` vide |
Note : la 3ème soumission (pas la 4ème) a déclenché le 429 car le test valide
précédent comptait comme 1ère soumission — comportement correct, limite = 3/jour.
---
## Navigation
- CTA "Proposer une pratique" dans `PratiqueSidebar.vue` (section bas sidebar, style `sidebar-cta-link`)
- Bouton retour dans `proposer-pratique.vue``/pratiques-regeneratives`
- Bouton Annuler dans le formulaire → `/pratiques-regeneratives`
---
## Notes implémentation
- `types/pratique.ts` non modifié — `CRITERES`, `TYPES_ENTITE`, `TYPES_ENTITE_LABELS`, `EUROPE_CODES`, `OUTREMER_CODES`, `PAYS_LABELS` importés tels quels
- Style réutilise 100% les classes CSS et variables CSS var(--nav-*) existantes
- Accentuation dans pending.json depuis curl Windows = artefact d'encodage terminal uniquement — depuis navigateur, l'encodage UTF-8 est correct
---
## Prêt pour P5

View File

@@ -1,59 +0,0 @@
# P5a — RECAP Build local + smoke test
> Date : 2026-04-28 | Branche : feat/aep-pratiques-regeneratives
## Build
- Statut : OK
- Bundle total : 3.04 MB (737 kB gzip)
- Warnings : 1 (DEP0155 — trailing slash dans @vue/shared/package.json, Node deprecation inoffensif, non-bloquant)
- Errors : 0
- Durée : client 5.38s + server 3.27s + Nitro OK
## Smoke test local (node-server Windows)
| Endpoint | HTTP | Note |
|---|---|---|
| GET /pratiques-regeneratives | 500 | Voir note ci-dessous |
| GET /proposer-pratique | 500 | Voir note ci-dessous |
| GET /api/pratiques | 200 | Retourne la liste JSON (N entries visible) |
| POST /api/submit-pratique | 429 | Rate limit local (comportement attendu) |
**Note sur les 500 SSR — BUG WINDOWS/NODE 24, non-bloquant pour le deploy VPS :**
Erreur : `Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'c:'`
Diagnostic : Nitro en mode `node-server` fait un `import()` dynamique avec chemin absolu Windows (`C:\...`) au lieu de `file:///C:/...`. Ce bug est systématique en local sur toutes les pages HTML (y compris `/`, `/a-propos`, etc.) — identique sur `main` avant cette branche. Il n'existe pas sur VPS Linux. Les APIs JSON ne sont pas affectées.
Conclusion : ce bug ne doit PAS bloquer le deploy P5b. Il est préexistant et propre à l'environnement Windows local.
## Console errors
- 1 warning DEP0155 (non-bloquant)
- 0 erreur critique
## Nettoyage
- pending.json : `[]` — propre (POST retourné 429, aucune entrée ajoutée)
- Processus preview/dev : stoppés
- git status : working tree propre (1 fichier non-tracké préexistant : `public/data/pratiques-regeneratives.json`)
## Branche
feat/aep-pratiques-regeneratives — 9 commits depuis main
```
9080855 docs(p4): recap P4 form proposer-pratique
f25a7d3 feat(pratiques): pending.json init + CTA sidebar proposer une pratique
d10586c feat(pratiques): page /proposer-pratique — formulaire contribution Pratique
83d4bd1 feat(pratiques): endpoint POST /api/submit-pratique avec Zod + rate limit
5fabcde feat(nav): ajout onglet Pratiques régé + hamburger + overflow
a70c9e0 feat(pratiques): types, API statique, composants filtres + cartes Europe/outremer
5eda4bd chore: supprimer fichiers tmp editeur parasites
21c44d8 feat(aep): carte AEP — push Gitea 2026-04-28
```
## Verdict
Build OK. Les 500 locaux SSR sont un artefact Windows/Node 24 non-reproductible sur VPS Linux. APIs fonctionnelles. Pending propre.
**Checkpoint Jules : OK pour deploy P5b ?**

View File

@@ -1,61 +0,0 @@
# P5b — Deploy prod + smoke test — Récap
> Date : 2026-04-29 | Branche : feat/aep-pratiques-regeneratives
## Pre-deploy
- Commit avant deploy : `90808551f003fe3e8e1cd227b433594b5e6f087a`
- Branche : `feat/aep-pratiques-regeneratives`
- Fichiers non-trackés : P5a-RECAP.md + pratiques-regeneratives.json (stagés + committés avant deploy)
- `.output/public/data/pratiques-regeneratives.json` : present dans le build (Nuxt le copie automatiquement)
- `.output/server/chunks/routes/api/pratiques.get.mjs` : present
- `.output/server/chunks/routes/api/submit-pratique.post.mjs` : present
## Deploy
- Méthode : `tar .output/ | ssh vps-hetzner "cd /opt/aep && tar -xzf -"` + `systemctl restart aep`
- Raison du contournement deploy.sh : BOM UTF-8 en ligne 1 (#!/bin/bash) cause un exit 1 au `read -p` quand stdin est un pipe. Les étapes ont été exécutées manuellement.
- Env diff local vs VPS : VPS a des vars supplémentaires (MISTRAL, NOCODB worker, RESEND) — additionnel non-conflictuel, deploy pas impacté.
- Durée upload : < 5s
- Service aep : active (systemctl is-active = "active")
## Output deploy (résumé)
```
[2026-04-29 01:37:08] Upload .output/ vers vps-hetzner:/opt/aep...
[2026-04-29 01:37:08] Upload termine.
[2026-04-29 01:37:51] Redemarrage du service aep...
active
[2026-04-29 01:37:51] Service aep statut verifie.
```
## Smoke test prod (2026-04-29 01:38 UTC)
| Endpoint | HTTP | Note |
|---|---|---|
| GET /pratiques-regeneratives | 200 | SSR OK |
| GET /proposer-pratique | 200 | SSR OK |
| GET /api/pratiques | 200 | JSON valid |
**Garde-fous additionnels :**
- SSR title check : `curl .../pratiques-regeneratives | grep -c "Pratiques"` → 2 occurrences trouvées
- JSON count : `node -e "..."` → 52 fiches (attendu : 52)
## Commits produits (P5b)
```
bf40b40 docs(p5b): journal deploy + INDEX + prompt BrowserMCP E2E
e80b226 docs(p5a): recap build local + add pratiques-regeneratives.json data
```
## Notes deploy.sh — TODO V3
deploy.sh a deux problèmes identifiés :
1. BOM UTF-8 en ligne 1 (`\xEF\xBB\xBF#!/bin/bash`) — cause exit 1 quand stdin est un pipe
2. Le script était documenté comme "contournement tar + ssh" dans JOURNAL-V2 V2 (Session S3b) — cohérent ici
A corriger : supprimer le BOM (`sed -i '1s/^\xEF\xBB\xBF//' deploy.sh`) + ajouter `CONFIRM=y` par défaut ou flag `--force-env`.
## Statut final
Deploy OK. Smoke test 3/3. Branche pushée. Jules merge main apres E2E.

View File

@@ -1,172 +0,0 @@
# PROMPT BrowserMCP — E2E Onglet Pratiques régénératives
> Cascade onglet 1 — à coller dans une session Claude Code avec BrowserMCP attaché à Brave.
> Date de création : 2026-04-29
---
## Mission
Tester en E2E les deux nouvelles pages de `aep.trans-former.fr` liées à l'onglet "Pratiques régénératives" : la carte principale `/pratiques-regeneratives` et le formulaire de contribution `/proposer-pratique`. Couvrir desktop ET mobile. Reporter tous les bugs trouvés (console JS, visuels, fonctionnels).
**URL de base :** `https://aep.trans-former.fr`
**Navigateur :** Brave avec extension BrowserMCP
**Mode :** Full auto, rapport final structuré
---
## Setup initial
1. Ouvre BrowserMCP et navigue vers `https://aep.trans-former.fr/pratiques-regeneratives`
2. Attends que la page soit fully loaded (pas de spinner Leaflet)
3. Prends un screenshot de référence `desktop-initial.png`
---
## Scenario 1 — Carte desktop (chargement + structure)
**Objectif :** vérifier que la carte s'affiche correctement avec markers et sidebar.
Étapes :
1. `navigate` vers `https://aep.trans-former.fr/pratiques-regeneratives`
2. Attendre 3s (Leaflet init)
3. `snapshot` — décrire ce qui est visible : carte Europe, sidebar gauche, onglet header actif
4. Vérifier dans le DOM :
- Présence d'au moins 1 marker Leaflet (chercher `.leaflet-marker-icon` ou `.leaflet-pane`)
- Sidebar visible avec des cartes de pratiques (chercher titres/noms)
- Bandeau DOM-TOM présent en bas (textes Guadeloupe/Martinique/Réunion...)
5. Ouvrir la console (`evaluate` `window.__AEP_DEBUG__ || 'no debug'`) — noter les erreurs JS si présentes
6. `evaluate` `fetch('/api/pratiques').then(r=>r.json()).then(d=>d.list.length)` — doit retourner 52
Bugs à surveiller :
- Carte blanche (Leaflet tiles non chargés)
- Markers manquants ou hors viewport
- CSS `--nav-primary-solid` non résolu (barre nav sans couleur)
- Erreurs console "leaflet", "vue warn", "hydration mismatch"
---
## Scenario 2 — Filtres sidebar (criteres + pays + type)
**Objectif :** vérifier que les filtres réduisent bien les résultats.
Étapes :
1. Depuis `/pratiques-regeneratives` desktop, noter le nombre de fiches dans la sidebar (label "X résultats" ou count visible)
2. Cliquer sur le chip "Matériaux" dans les filtres critères
3. Attendre 500ms — `snapshot` — noter le nouveau count
4. Cliquer sur le chip "Pays" (ou sélectionner France dans le filtre pays si c'est un select)
5. `snapshot` — noter le count (doit être inférieur ou égal à l'étape 3)
6. Cliquer sur le type "Coopérative" si accessible
7. `snapshot` — noter le count
8. Cliquer "Réinitialiser" ou le bouton reset — vérifier que le count revient au total (52)
Bugs à surveiller :
- Filtre appliqué mais count ne change pas
- Markers sur la carte non synchronisés avec la sidebar
- Reset qui ne fonctionne pas
- Chip actif sans indicateur visuel
---
## Scenario 3 — Fiche pratique (clic marker ou card + retour)
**Objectif :** vérifier que l'ouverture d'une fiche fonctionne et que le retour préserve les filtres.
Étapes :
1. Appliquer un filtre (ex: "Matériaux") pour avoir < 52 résultats
2. Cliquer sur une card dans la sidebar OU un marker sur la carte
3. `snapshot` — décrire ce qui est affiché : modal ? Page fiche ? URL change ?
4. Vérifier que la fiche contient : nom, description, critères, type, pays, URL (si disponible)
5. Cliquer le bouton "Retour" ou `navigateBack`
6. `snapshot` — vérifier que le filtre "Matériaux" est toujours actif et le count identique à avant
Bugs à surveiller :
- Clic sur card ne navigue pas
- Page fiche vide ou 404
- Bouton retour recharge sans filtres
- Scroll position perdu
---
## Scenario 4 — Formulaire de contribution
**Objectif :** soumettre une proposition valide et vérifier le message de succès.
Étapes :
1. `navigate` vers `https://aep.trans-former.fr/proposer-pratique`
2. `snapshot` — décrire le formulaire visible
3. Remplir les champs suivants :
- `#nom` : "Test BrowserMCP E2E"
- `#url` : "https://example.com/test-e2e"
- `#description_user` : "Pratique de test soumise automatiquement par BrowserMCP pour valider le pipeline de contribution de l'onglet Pratiques régénératives. Cette description fait plus de 100 caractères."
- Critères : cocher au moins 3 checkboxes (ex: "Matériaux", "Posture", "Vivant")
- Type d'entité : sélectionner "Collectif"
- Pays : sélectionner "France" dans le select
4. `snapshot` avant envoi — vérifier que les champs sont remplis
5. Cliquer le bouton `submit` du formulaire
6. Attendre 3s
7. `snapshot` — chercher le message de succès "Merci !" + "Ta proposition est en attente de modération."
Bugs à surveiller :
- Validation côté client ne se déclenche pas
- Submit retourne une erreur réseau (verifier dans console : `fetch POST /api/submit-pratique`)
- Message succès ne s'affiche pas
- Rate limit 429 (normal si on a soumis plusieurs fois — attendre 1h ou ignorer si premier test)
Note : si rate limit 429, c'est le comportement attendu. Réessayer en changeant légèrement le nom.
---
## Scenario 5 — Mobile (viewport 375x667)
**Objectif :** valider l'expérience mobile de base.
Étapes :
1. Changer le viewport à 375x667 (iPhone SE) via `setViewport` ou équivalent BrowserMCP
2. `navigate` vers `https://aep.trans-former.fr/pratiques-regeneratives`
3. Attendre 3s
4. `snapshot` — décrire : sheet en bas ? Carte en fond plein écran ? Header avec hamburger ?
5. Tenter d'interagir avec la sheet du bas (si elle existe) : tapper sur le header de la sheet pour l'agrandir
6. `snapshot` — sheet en mode "half" ou "full" ?
7. Vérifier que les filtres sont accessibles (dans la sheet ou dans un drawer)
8. Cliquer une card si visible
9. `snapshot` — fiche pleine page ou modal plein écran ?
10. `navigate` vers `https://aep.trans-former.fr/proposer-pratique`
11. `snapshot` — formulaire lisible et accessible sur mobile ?
Bugs à surveiller :
- Sheet absente ou non draggable
- Carte non visible derrière la sheet
- Hamburger nav manquant
- Formulaire /proposer-pratique avec overflow horizontal
---
## Format de récap final attendu
```
Cascade onglet 1 — E2E [PASS / FAIL avec [N] bugs H]
Scénarios : [N]/5 OK
Bugs : [N] High / [N] Medium / [N] Low
Erreurs console : [N] (liste si > 0)
Détail par scénario :
S1 Carte desktop : [PASS/FAIL] — [note courte]
S2 Filtres : [PASS/FAIL] — [note courte]
S3 Fiche + retour: [PASS/FAIL] — [note courte]
S4 Form submit : [PASS/FAIL] — [note courte]
S5 Mobile : [PASS/FAIL] — [note courte]
Bugs H :
- [description + étape]
Bugs M :
- [description + étape]
Bugs L :
- [description + étape]
Screenshots : [N] pris
```
Envoyer ce récap à Jules (copier dans la session pilote ou dans `aep-communaute-build/E2E-RESULTS.md`).

28
app.vue
View File

@@ -34,13 +34,6 @@
> >
Écosystème Entraide Architecture Écosystème Entraide Architecture
</NuxtLink> </NuxtLink>
<NuxtLink
to="/pratiques-regeneratives"
class="nav-tab"
:class="{ 'nav-tab--active': route.path.startsWith('/pratiques-regeneratives') || route.path.startsWith('/pratique/') }"
>
Pratiques régé
</NuxtLink>
<NuxtLink <NuxtLink
to="/agences" to="/agences"
class="nav-tab" class="nav-tab"
@@ -107,9 +100,9 @@
> >
Signaler Signaler
</NuxtLink> </NuxtLink>
<!-- Proposer une ressource (cible contextuelle selon la carte active) --> <!-- Proposer une ressource -->
<NuxtLink <NuxtLink
:to="proposeTarget" to="/contribuer"
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1" class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
style="background: var(--nav-accent); color: var(--nav-text);" style="background: var(--nav-accent); color: var(--nav-text);"
> >
@@ -136,9 +129,9 @@
</svg> </svg>
</button> </button>
<!-- Mobile : contribuer icône (cible contextuelle) --> <!-- Mobile : contribuer icône -->
<NuxtLink <NuxtLink
:to="proposeTarget" to="/contribuer"
class="sm:hidden p-2 rounded-lg" class="sm:hidden p-2 rounded-lg"
style="background: var(--nav-accent); color: var(--nav-text);" style="background: var(--nav-accent); color: var(--nav-text);"
title="Contribuer une fiche" title="Contribuer une fiche"
@@ -172,7 +165,6 @@
@click="hamburgerOpen = false" @click="hamburgerOpen = false"
> >
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink> <NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
<NuxtLink to="/pratiques-regeneratives" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/pratiques-regeneratives') || route.path.startsWith('/pratique/') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Pratiques régé</NuxtLink>
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Agences Inspirantes</NuxtLink> <NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Agences Inspirantes</NuxtLink>
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink> <NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div> <div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
@@ -184,7 +176,7 @@
</header> </header>
<!-- Contenu page (flex-1 pour remplir l'espace) --> <!-- Contenu page (flex-1 pour remplir l'espace) -->
<div class="flex-1" :class="(route.path === '/' || route.path === '/pratiques-regeneratives') ? 'overflow-hidden' : 'overflow-y-auto'"> <div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
<NuxtPage /> <NuxtPage />
</div> </div>
@@ -253,16 +245,6 @@ function clearHeaderSearch() {
function goRandom() { function goRandom() {
router.push({ path: '/', query: { random: '1' } }) router.push({ path: '/', query: { random: '1' } })
} }
// ── Cible contextuelle du bouton Proposer ────────────────────────────────
// Sur l'onglet pratiques regeneratives, route vers /proposer-pratique.
// Sur l'onglet ecosysteme AEP (et toute autre route), route vers /contribuer.
const proposeTarget = computed(() => {
if (route.path.startsWith('/pratiques-regeneratives') || route.path.startsWith('/pratique/')) {
return '/proposer-pratique'
}
return '/contribuer'
})
</script> </script>
<style> <style>

View File

@@ -461,12 +461,10 @@ const jaugePct = computed(() => {
} }
/* ── FAB mobile soutenir ─────────────────────────────────────────────────── */ /* ── FAB mobile soutenir ─────────────────────────────────────────────────── */
/* Stack vertical avec le FAB Chatbot a droite (evite l'overlap avec les chips
sidebar mobile sur viewport intermediaire ~880px - bug E2E M3) */
.fab-soutenir { .fab-soutenir {
position: fixed; position: fixed;
bottom: 84px; /* au-dessus du FAB chatbot a bottom-6 (24px) + 48px de hauteur + 12px gap */ bottom: 68px; /* au-dessus du FAB chatbot à 24px du bas + 48px de hauteur */
right: 16px; left: 16px;
z-index: 1000; z-index: 1000;
width: 44px; width: 44px;
height: 44px; height: 44px;

View File

@@ -52,9 +52,18 @@
<div class="chatbot-body-inner" ref="messagesContainer"> <div class="chatbot-body-inner" ref="messagesContainer">
<!-- Onboarding --> <!-- Onboarding -->
<div v-if="messages.length === 0" class="onboarding-bubble"> <div v-if="messages.length === 0" class="onboarding-bubble">
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p> <p>Ce chatbot fonctionne sur un serveur européen souverain
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p> (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR - serveur européen souverain, zéro rétention.</p> <p>Pour m'aider à te répondre efficacement,
formule ta requête ainsi :</p>
<ul>
<li>• Besoin : [ce que tu cherches]</li>
<li>• Thématique : [juridique / technique / économique / ...]</li>
<li>• Lieu : [région ou ville]</li>
</ul>
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
employeur, besoin conseil juridique droit du travail,
Île-de-France."</p>
</div> </div>
<!-- Messages --> <!-- Messages -->
@@ -63,7 +72,7 @@
<div v-else class="assistant-bubble"> <div v-else class="assistant-bubble">
<p>{{ msg.content }}</p> <p>{{ msg.content }}</p>
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list"> <div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
<p class="fiches-title">Fiches recommandees :</p> <p class="fiches-title">Fiches recommandées :</p>
<a <a
v-for="fiche in msg.fiches" v-for="fiche in msg.fiches"
:key="fiche.id" :key="fiche.id"
@@ -74,21 +83,6 @@
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span> <span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
</a> </a>
</div> </div>
<div v-if="msg.suggestedHashtags && msg.suggestedHashtags.length" style="margin-top: 8px;">
<p style="font-size: 0.7rem; color: var(--nav-text-muted); margin-bottom: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;">Filtrer par :</p>
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
<span
v-for="tag in msg.suggestedHashtags"
:key="tag"
style="
padding: 2px 8px; border-radius: 9999px; font-size: 0.7rem; cursor: pointer;
background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid var(--nav-bg-alt);
transition: all 0.15s;
"
@click="emit('applyHashtag', tag)"
>{{ tag }}</span>
</div>
</div>
</div> </div>
</template> </template>
@@ -138,12 +132,10 @@ interface ChatMessage {
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
fiches?: FicheReco[] fiches?: FicheReco[]
suggestedHashtags?: string[]
} }
const emit = defineEmits<{ const emit = defineEmits<{
'highlightOrgs': [ids: (number | string)[]] 'highlightOrgs': [ids: (number | string)[]]
'applyHashtag': [tag: string]
}>() }>()
const isExpanded = ref(false) const isExpanded = ref(false)
@@ -153,37 +145,6 @@ const loading = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
const messagesContainer = ref<HTMLElement | null>(null) const messagesContainer = ref<HTMLElement | null>(null)
// Detection hashtags depuis la question posee
const HASHTAG_KEYWORDS: Record<string, string[]> = {
'#reemploi-structurel': ['reemploi', 'materiaux recuperes', 'deconstruction', 'reemploi structurel'],
'#reemploi-second-oeuvre': ['revetement', 'second oeuvre', 'reemploi'],
'#biosource-geosource': ['biosource', 'geosource', 'paille', 'terre', 'chanvre', 'lin', 'biosource'],
'#low-tech-experimentation': ['low-tech', 'low tech', 'technique simple', 'autonomie', 'lowtech'],
'#chantier-ecole': ['formation', 'chantier ecole', 'chantier-ecole', 'apprendre', 'auto-construction', 'autoconstruction'],
'#sobriete-energetique': ['sobriete', 'energie', 'renovation energetique', 'isolation', 'chauffage', 'economie energie'],
'#mal-logement-precarite': ['mal-logement', 'precarite', 'sans-abri', 'logement social', 'squat', 'mal logement'],
'#tiers-lieux-friches': ['friche', 'tiers-lieu', 'tiers lieu', 'espace intermediaire', 'temporaire', 'reconversion'],
'#accompagnement-cooperatif': ['cooperative', 'accompagnement', 'cooperation', 'collectif', 'mutualisation'],
'#transition-energetique-territoriale': ['territoire', 'transition', 'energetique', 'local', 'region', 'transition energetique'],
'#communs-fonciers': ['communs', 'foncier', 'anti-speculatif', 'community land trust', 'commun foncier'],
'#hack-juridique': ['juridique', 'montage', 'structure legale', 'sci', 'cooperative', 'statut'],
'#retrofit-strates': ['retrofit', 'renovation lourde', 'sur-isolation', 'rehaussement'],
'#phytoconstruction': ['plantes', 'vegetal', 'arbre', 'construction vivante', 'phyto'],
}
function detectHashtagsFromQuery(query: string): string[] {
const q = query.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
const detected: string[] = []
for (const [hashtag, keywords] of Object.entries(HASHTAG_KEYWORDS)) {
if (keywords.some(kw => q.includes(kw))) {
detected.push(hashtag)
}
}
return detected.slice(0, 3)
}
function toggleExpand() { function toggleExpand() {
isExpanded.value = !isExpanded.value isExpanded.value = !isExpanded.value
} }
@@ -204,17 +165,15 @@ async function sendMessage() {
const res = await $fetch<{ const res = await $fetch<{
reponse_texte: string reponse_texte: string
fiches_recommandees: { id: number | string; nom: string; explication: string }[] fiches_recommandees: { id: number | string; nom: string; explication: string }[]
}>('/api/chatbot-v2', { }>('/api/chatbot', {
method: 'POST', method: 'POST',
body: { question }, body: { question },
}) })
const suggestedHashtags = detectHashtagsFromQuery(question)
const assistantMsg: ChatMessage = { const assistantMsg: ChatMessage = {
role: 'assistant', role: 'assistant',
content: res.reponse_texte, content: res.reponse_texte,
fiches: res.fiches_recommandees || [], fiches: res.fiches_recommandees || [],
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
} }
messages.value.push(assistantMsg) messages.value.push(assistantMsg)

View File

@@ -61,7 +61,7 @@
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/> <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg> </svg>
</div> </div>
<span class="font-bold text-sm" style="color: var(--nav-text);">{{ title }}</span> <span class="font-bold text-sm" style="color: var(--nav-text);">Chatbot</span>
</div> </div>
</div> </div>
@@ -69,7 +69,6 @@
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3"> <div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
<!-- Message onboarding (avant la première question) --> <!-- Message onboarding (avant la première question) -->
<div v-if="messages.length === 0" class="onboarding-bubble"> <div v-if="messages.length === 0" class="onboarding-bubble">
<slot name="onboarding">
<p>Ce chatbot fonctionne sur un serveur européen souverain <p>Ce chatbot fonctionne sur un serveur européen souverain
(Mistral FR, zéro rétention), conçu sobre en énergie.</p> (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
<p>Pour m'aider à te répondre efficacement, <p>Pour m'aider à te répondre efficacement,
@@ -82,7 +81,6 @@ formule ta requête ainsi :</p>
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon <p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
employeur, besoin conseil juridique droit du travail, employeur, besoin conseil juridique droit du travail,
Île-de-France."</p> Île-de-France."</p>
</slot>
</div> </div>
<!-- Messages --> <!-- Messages -->
@@ -102,7 +100,7 @@ employeur, besoin conseil juridique droit du travail,
<a <a
v-for="fiche in msg.fiches" v-for="fiche in msg.fiches"
:key="fiche.id" :key="fiche.id"
:href="`${ficheBasePath}/${fiche.id}`" :href="`/fiche/${fiche.id}`"
class="fiche-card" class="fiche-card"
> >
<span class="fiche-nom">{{ fiche.nom }}</span> <span class="fiche-nom">{{ fiche.nom }}</span>
@@ -178,16 +176,9 @@ interface ChatMessage {
fiches?: FicheReco[] fiches?: FicheReco[]
} }
const props = withDefaults(defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
endpoint?: string }>()
title?: string
ficheBasePath?: string
}>(), {
endpoint: '/api/chatbot',
title: 'Chatbot',
ficheBasePath: '/fiche',
})
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
@@ -234,7 +225,7 @@ async function sendMessage() {
const res = await $fetch<{ const res = await $fetch<{
reponse_texte: string reponse_texte: string
fiches_recommandees: { id: number | string; nom: string; explication: string }[] fiches_recommandees: { id: number | string; nom: string; explication: string }[]
}>(props.endpoint, { }>('/api/chatbot', {
method: 'POST', method: 'POST',
body: { question }, body: { question },
}) })

View File

@@ -1,41 +0,0 @@
<template>
<div>
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">CRITÈRES RÉGÉ</span>
<div class="flex flex-wrap gap-1">
<button
v-for="critere in CRITERES"
:key="critere.id"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(critere.id)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(critere.id)"
>
{{ critere.label }}
<span v-if="counts && counts[critere.id] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[critere.id] }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { CRITERES } from '~/types/pratique'
const props = defineProps<{
modelValue: number[]
counts?: Record<number, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: number[]]
}>()
function toggle(id: number) {
if (props.modelValue.includes(id)) {
emit('update:modelValue', props.modelValue.filter(v => v !== id))
} else {
emit('update:modelValue', [...props.modelValue, id])
}
}
</script>

View File

@@ -1,253 +0,0 @@
<template>
<div class="relative w-full h-full">
<div ref="mapContainer" class="w-full h-full rounded-none" />
</div>
</template>
<script setup lang="ts">
import type { Map, Marker, DivIcon } from 'leaflet'
interface Pratique {
id: number
nom: string
lat?: number | null
lng?: number | null
pays?: string
ville?: string
type?: string
score?: number
}
const props = defineProps<{
orgs: Pratique[]
selectedId?: number | null
}>()
const emit = defineEmits<{
'select-org': [id: number]
}>()
const mapContainer = ref<HTMLElement | null>(null)
let mapInstance: Map | null = null
let clusterGroup: any = null
const markers = new Map<number, Marker>()
let tileLayerInstance: any = null
function createPinIcon(score: number, isSelected = false): DivIcon {
const L = (window as any).L
// Couleur selon score (1-5) : du pale au vif
const bg = score >= 4 ? '#f5b342' : score >= 3 ? 'rgba(26,34,56,0.75)' : 'rgba(26,34,56,0.5)'
const border = isSelected ? '#f5b342' : '#ffffff'
const size = isSelected ? 18 : 14
const shadow = isSelected ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
return L.divIcon({
className: '',
html: `<div style="
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: ${bg};
border: 2px solid ${border};
box-shadow: ${shadow};
transition: all 0.2s;
"></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 4)],
})
}
async function initMap() {
if (!mapContainer.value) return
const Lmod = await import('leaflet')
const L: any = (Lmod as any).default || Lmod
await import('leaflet/dist/leaflet.css')
// @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.css')
// @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
;(window as any).L = L
// @ts-ignore
await import('leaflet.markercluster')
const MarkerClusterGroup = L.MarkerClusterGroup
mapInstance = L.map(mapContainer.value, {
center: [50.0, 10.0],
zoom: 4,
zoomControl: true,
attributionControl: true,
maxBounds: [[30.0, -15.0], [72.0, 40.0]],
maxBoundsViscosity: 0.8,
minZoom: 3,
maxZoom: 18,
})
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance = L.tileLayer(tileUrl, {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 19,
})
tileLayerInstance.addTo(mapInstance!)
clusterGroup = new MarkerClusterGroup({
disableClusteringAtZoom: 12,
maxClusterRadius: 50,
showCoverageOnHover: false,
iconCreateFunction: (cluster: any) => {
const count = cluster.getChildCount()
return L.divIcon({
html: `<div style="
width: 36px; height: 36px; border-radius: 50%;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px;
border: 2px solid white;
font-family: var(--nav-font);
">${count}</div>`,
className: '',
iconSize: [36, 36],
iconAnchor: [18, 18],
})
},
})
mapInstance.addLayer(clusterGroup)
updateMarkers(L)
}
// Vue initiale (centre Europe + zoom 4) - sauvegardee pour reset
const INITIAL_CENTER: [number, number] = [50.0, 10.0]
const INITIAL_ZOOM = 4
let initialFitDone = false
function updateMarkers(L?: any) {
if (!mapInstance || !clusterGroup) return
const leaflet = L || (window as any).L
if (!leaflet) return
clusterGroup.clearLayers()
markers.clear()
const orgsWithCoords = props.orgs.filter(
(o) => o.lat != null && o.lng != null
)
orgsWithCoords.forEach((org) => {
const isSelected = org.id === props.selectedId
const icon = createPinIcon(org.score ?? 1, isSelected)
const marker = leaflet.marker([org.lat!, org.lng!], { icon })
marker.bindPopup(`
<div style="font-family: var(--nav-font); min-width: 180px; padding: 4px 0;">
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 4px;">${org.nom}</div>
${org.pays ? `<div style="font-size: 11px; color: var(--nav-text-muted);">${org.pays}${org.ville ? ' · ' + org.ville : ''}</div>` : ''}
${org.type ? `<div style="font-size: 11px; color: var(--nav-text-muted); margin-top: 2px;">${org.type}</div>` : ''}
<a href="/pratique/${org.id}" style="
display: inline-block; margin-top: 8px; font-size: 12px;
color: var(--nav-primary-solid); text-decoration: underline;
">Voir la fiche →</a>
</div>
`, { maxWidth: 240 })
marker.on('click', () => emit('select-org', org.id))
markers.set(org.id, marker)
clusterGroup.addLayer(marker)
})
// Bug E2E L3 : recadrer la carte sur les resultats filtres
// Conditions : 1+ resultat, et au moins 1 marker hors viewport actuel.
// On evite de recadrer au tout premier rendu (laisser la vue initiale).
if (orgsWithCoords.length > 0 && initialFitDone) {
try {
const bounds = leaflet.latLngBounds(
orgsWithCoords.map((o) => [o.lat!, o.lng!])
)
// On recadre uniquement si la liste filtree est restreinte
// (evite un recadrage permanent quand toutes les fiches sont la).
if (orgsWithCoords.length <= 15) {
mapInstance.fitBounds(bounds, {
padding: [40, 40],
maxZoom: 10,
animate: true,
})
}
} catch (e) {
console.warn('[EuropeMap] fitBounds echoue:', e)
}
}
initialFitDone = true
}
watch(
() => props.orgs,
() => updateMarkers(),
{ deep: false }
)
watch(
() => props.selectedId,
(newId, oldId) => {
if (!mapInstance) return
const leaflet = (window as any).L
if (!leaflet) return
if (oldId != null) {
const oldMarker = markers.get(oldId)
const oldOrg = props.orgs.find(o => o.id === oldId)
if (oldMarker && oldOrg) {
oldMarker.setIcon(createPinIcon(oldOrg.score ?? 1, false))
}
}
if (newId != null) {
const newMarker = markers.get(newId)
const newOrg = props.orgs.find(o => o.id === newId)
if (newMarker && newOrg) {
newMarker.setIcon(createPinIcon(newOrg.score ?? 1, true))
const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true })
}
}
}
)
function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L
if (!L) return
const url = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance.setUrl(url)
}
let themeObserver: MutationObserver | null = null
onMounted(() => {
initMap()
themeObserver = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark')
updateTileTheme(dark)
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
onUnmounted(() => {
themeObserver?.disconnect()
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
})
</script>

View File

@@ -1,435 +0,0 @@
<template>
<div class="graph-view" style="width: 100%; height: 100%; position: relative; background: var(--nav-bg);">
<!-- Canvas SVG pour D3 (zone centrale, marge droite pour sidebar) -->
<svg
ref="svgRef"
:style="{
width: sidebarOpen ? 'calc(100% - 200px)' : 'calc(100% - 40px)',
height: '100%',
transition: 'width 0.2s ease',
}"
></svg>
<!-- Sidebar hashtags droite (repliable) -->
<aside
:style="{
position: 'absolute', top: '0', right: '0', bottom: '0',
width: sidebarOpen ? '200px' : '40px',
background: 'var(--nav-surface)',
borderLeft: '1px solid var(--nav-bg-alt)',
display: 'flex', flexDirection: 'column',
transition: 'width 0.2s ease',
zIndex: 10,
}"
>
<!-- Toggle (toujours visible) -->
<button
@click="sidebarOpen = !sidebarOpen"
:title="sidebarOpen ? 'Replier la sidebar' : 'Deplier la sidebar'"
style="
width: 100%; height: 36px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: var(--nav-bg-alt); border: none; cursor: pointer;
color: var(--nav-text-muted); font-size: 0.78rem; font-weight: 700;
border-bottom: 1px solid var(--nav-bg-alt);
"
>{{ sidebarOpen ? '>>' : '<<' }}</button>
<!-- Mode replie : label vertical -->
<div
v-if="!sidebarOpen"
style="
flex: 1; display: flex; align-items: center; justify-content: center;
writing-mode: vertical-rl; transform: rotate(180deg);
font-size: 0.7rem; font-weight: 700; color: var(--nav-text-muted);
letter-spacing: 0.12em; text-transform: uppercase;
"
>HASHTAGS ({{ activeHashtags.length }}/{{ props.allHashtags.length }})</div>
<!-- Mode deplie : header + liste groupee -->
<template v-if="sidebarOpen">
<div style="padding: 8px 12px; border-bottom: 1px solid var(--nav-bg-alt); flex-shrink: 0;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<span style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em;">Hashtags</span>
<span style="font-size: 0.68rem; color: var(--nav-text-muted);">{{ activeHashtags.length }} actif{{ activeHashtags.length > 1 ? 's' : '' }}</span>
</div>
<button
v-if="activeHashtags.length"
@click="activeHashtags = []"
style="margin-top: 4px; font-size: 0.68rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
>Tout effacer</button>
</div>
<div style="flex: 1; overflow-y: auto; padding: 6px 10px 10px;">
<div
v-for="group in hashtagsByFamille"
:key="group.famille"
style="margin-bottom: 10px;"
>
<div
:style="{
fontSize: '0.65rem', fontWeight: 700,
color: group.color, textTransform: 'uppercase',
letterSpacing: '0.06em', marginBottom: '4px',
paddingLeft: '2px',
}"
>{{ group.label }}</div>
<div style="display: flex; flex-wrap: wrap; gap: 3px;">
<span
v-for="tag in group.tags"
:key="tag"
style="padding: 2px 7px; border-radius: 9999px; font-size: 0.66rem; cursor: pointer; transition: all 0.12s;"
:style="activeHashtags.includes(tag)
? `background: ${group.color}; color: white; font-weight: 600;`
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleHashtag(tag)"
>{{ tag }}</span>
</div>
</div>
</div>
</template>
</aside>
<!-- Tooltip -->
<div ref="tooltipRef" style="
position: absolute; pointer-events: none;
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
color: var(--nav-text); max-width: 220px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0; transition: opacity 0.15s; z-index: 100;
"></div>
</div>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData } from '~/types/structure-v2'
const props = defineProps<{
data: ReseauxBifurcationData | null
allHashtags: string[]
active?: boolean
}>()
const emit = defineEmits<{
'select-structure': [id: string]
}>()
const svgRef = ref<SVGElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
// Hashtag filter
const activeHashtags = ref<string[]>([])
const sidebarOpen = ref(true)
function toggleHashtag(tag: string) {
activeHashtags.value = activeHashtags.value.includes(tag)
? activeHashtags.value.filter(t => t !== tag)
: [...activeHashtags.value, tag]
}
// Mapping hashtag -> famille majoritaire
// En cas d'egalite : prendre la famille la plus petite (visibilite minoritaires)
const hashtagsByFamille = computed(() => {
if (!props.data) return []
// 1. Pour chaque hashtag, compter les structures par famille
const counts: Record<string, Record<number, number>> = {}
props.data.structures.forEach(s => {
s.hashtags.forEach(tag => {
if (!counts[tag]) counts[tag] = {}
counts[tag][s.famille_principale] = (counts[tag][s.famille_principale] ?? 0) + 1
})
})
// 2. Pour chaque hashtag, trouver la famille majoritaire (egalite -> + petite famille)
// Pour preferer la famille la moins peuplee globalement, calculer la taille de chaque famille.
const familleSize: Record<number, number> = {}
props.data.structures.forEach(s => {
familleSize[s.famille_principale] = (familleSize[s.famille_principale] ?? 0) + 1
})
const tagToFamille: Record<string, number> = {}
for (const tag in counts) {
const entries = Object.entries(counts[tag])
entries.sort((a, b) => {
const diff = (b[1] as number) - (a[1] as number)
if (diff !== 0) return diff
// egalite : famille avec moins de structures gagne
return (familleSize[Number(a[0])] ?? 0) - (familleSize[Number(b[0])] ?? 0)
})
tagToFamille[tag] = Number(entries[0][0])
}
// 3. Grouper les hashtags par famille
const groups: Record<number, string[]> = {}
props.allHashtags.forEach(tag => {
const fam = tagToFamille[tag]
if (fam == null) return
if (!groups[fam]) groups[fam] = []
groups[fam].push(tag)
})
// 4. Sortie ordonnee selon ID de famille
return [1, 2, 3, 4, 5, 6]
.filter(famId => groups[famId]?.length)
.map(famId => ({
famille: famId,
label: FAMILLE_LABELS[famId],
color: FAMILLE_COLORS[famId],
tags: groups[famId].sort(),
}))
})
// IDs de structures correspondant aux hashtags actifs
const filteredStructureIds = computed(() => {
if (!props.data || !activeHashtags.value.length) return null
const ids = new Set(
props.data.structures
.filter(s => activeHashtags.value.every(h => s.hashtags.includes(h)))
.map(s => s.id)
)
return ids
})
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
const FAMILLE_LABELS: Record<number, string> = {
1: 'Reemploi',
2: 'Frugalite',
3: 'Social',
4: 'Collectifs',
5: 'Urbanisme',
6: 'Recherche',
}
let simulation: any = null
let d3NodeSelection: any = null
let d3LinkSelection: any = null
async function initGraph() {
if (!svgRef.value || !props.data) return
const d3 = await import('d3')
const svgEl = svgRef.value
const width = svgEl.clientWidth || 800
const height = svgEl.clientHeight || 600
// Nettoyer
d3.select(svgEl).selectAll('*').remove()
const svg = d3.select(svgEl)
.attr('viewBox', `0 0 ${width} ${height}`)
// Groupe principal avec zoom
const g = svg.append('g')
const zoomBehavior = d3.zoom<SVGElement, unknown>()
.scaleExtent([0.2, 4])
.on('zoom', (event) => g.attr('transform', event.transform))
svg.call(zoomBehavior as any)
// Noeuds familles (centres fixes en etoile)
const familyNodes = [1, 2, 3, 4, 5, 6].map(id => ({
id: `family-${id}`,
type: 'family',
familleId: id,
label: FAMILLE_LABELS[id],
color: FAMILLE_COLORS[id],
r: 32,
x: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
y: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
fx: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
fy: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
}))
// Noeuds structures
const structureNodes = props.data.structures.map(s => ({
id: s.id,
type: 'structure',
label: s.nom,
famille: s.famille_principale,
familles_secondaires: s.familles_secondaires ?? [],
hashtags: s.hashtags,
color: FAMILLE_COLORS[s.famille_principale] ?? '#888',
r: 8,
x: undefined as number | undefined,
y: undefined as number | undefined,
fx: undefined as number | null | undefined,
fy: undefined as number | null | undefined,
}))
const allNodes: any[] = [...familyNodes, ...structureNodes]
// Liens structures -> familles
const links: any[] = []
structureNodes.forEach(s => {
links.push({
source: s.id,
target: `family-${s.famille}`,
type: 'primary',
strength: 0.55,
})
s.familles_secondaires.forEach((f: number) => {
links.push({
source: s.id,
target: `family-${f}`,
type: 'secondary',
strength: 0.45,
})
})
})
// Simulation force-directed
if (simulation) simulation.stop()
simulation = d3.forceSimulation(allNodes)
.force('link', d3.forceLink(links).id((d: any) => d.id).distance((d: any) => d.type === 'primary' ? 80 : 120).strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(-120))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius((d: any) => d.r + 4))
// Rendu liens
d3LinkSelection = g.append('g').selectAll('line')
.data(links)
.join('line')
.attr('stroke', (d: any) => d.type === 'primary' ? 'rgba(150,150,150,0.45)' : 'rgba(150,150,150,0.35)')
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', null)
// Rendu noeuds (groupes g)
d3NodeSelection = g.append('g').selectAll('g')
.data(allNodes)
.join('g')
.style('cursor', (d: any) => d.type === 'structure' ? 'pointer' : 'default')
.call(
d3.drag<any, any>()
.on('start', (event: any, d: any) => {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event: any, d: any) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event: any, d: any) => {
if (!event.active) simulation.alphaTarget(0)
if (d.type !== 'family') {
d.fx = null
d.fy = null
}
})
)
.on('click', (_event: any, d: any) => {
if (d.type === 'structure') emit('select-structure', d.id)
})
// Cercles
d3NodeSelection.append('circle')
.attr('r', (d: any) => d.r)
.attr('fill', (d: any) => d.type === 'family' ? d.color : d.color + 'cc')
.attr('stroke', (d: any) => d.type === 'family' ? 'white' : d.color)
.attr('stroke-width', (d: any) => d.type === 'family' ? 3 : 1.5)
// Labels familles
d3NodeSelection.filter((d: any) => d.type === 'family')
.append('text')
.text((d: any) => d.label)
.attr('text-anchor', 'middle')
.attr('dy', '0.35em')
.attr('font-size', '11px')
.attr('font-weight', '700')
.attr('fill', 'white')
.style('pointer-events', 'none')
// Tooltip hover pour structures
d3NodeSelection.filter((d: any) => d.type === 'structure')
.on('mouseenter', (_event: any, d: any) => {
if (!tooltipRef.value) return
tooltipRef.value.style.opacity = '1'
tooltipRef.value.innerHTML = `<strong>${d.label}</strong><br><span style="opacity:0.6;font-size:0.7rem;">${FAMILLE_LABELS[d.famille] ?? ''}</span>`
})
.on('mousemove', (event: any) => {
if (!tooltipRef.value || !svgEl) return
const rect = (svgEl as HTMLElement).getBoundingClientRect()
tooltipRef.value.style.left = (event.clientX - rect.left + 12) + 'px'
tooltipRef.value.style.top = (event.clientY - rect.top - 10) + 'px'
})
.on('mouseleave', () => {
if (tooltipRef.value) tooltipRef.value.style.opacity = '0'
})
// Tick - mise a jour positions
simulation.on('tick', () => {
d3LinkSelection
.attr('x1', (d: any) => d.source.x)
.attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x)
.attr('y2', (d: any) => d.target.y)
d3NodeSelection.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
// Surlignage selon hashtags actifs
applyHashtagFilter()
})
}
function applyHashtagFilter() {
if (!d3NodeSelection || !d3LinkSelection) return
if (filteredStructureIds.value) {
const ids = filteredStructureIds.value
d3NodeSelection.filter((d: any) => d.type === 'structure').select('circle')
.attr('opacity', (d: any) => ids.has(d.id) ? 1 : 0.1)
d3LinkSelection.attr('opacity', (d: any) => {
const srcId = typeof d.source === 'object' ? d.source.id : d.source
return ids.has(srcId) ? 1 : 0.05
})
} else {
d3NodeSelection.select('circle').attr('opacity', 1)
d3LinkSelection.attr('opacity', 1)
}
}
// Déclencher quand l'onglet devient visible
// Double rAF : nextTick met à jour le vdom, les 2 frames garantissent que
// le browser a calculé le layout et que clientWidth/clientHeight != 0
watch(() => props.active, (val) => {
if (val && import.meta.client && props.data) {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}
})
// Relancer si les données arrivent après l'activation
watch(() => props.data, (val) => {
if (val && props.active && import.meta.client) {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}
})
// Re-appliquer le filtre visuel sans rebuild complet
watch(activeHashtags, () => {
applyHashtagFilter()
if (simulation) simulation.alpha(0.01).restart()
}, { deep: true })
// Toggle sidebar : largeur SVG change -> reinit graphe apres transition CSS
watch(sidebarOpen, () => {
if (!import.meta.client || !props.active || !props.data) return
setTimeout(() => {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}, 220)
})
onMounted(async () => {
if (import.meta.client && props.data && props.active) {
await nextTick()
initGraph()
}
})
onUnmounted(() => {
if (simulation) simulation.stop()
})
</script>

View File

@@ -128,8 +128,6 @@ async function initMap() {
updateMarkers(L) updateMarkers(L)
} }
let initialFitDone = false
function updateMarkers(L?: any) { function updateMarkers(L?: any) {
if (!mapInstance || !clusterGroup) return if (!mapInstance || !clusterGroup) return
const leaflet = L || (window as any).L const leaflet = L || (window as any).L
@@ -170,25 +168,6 @@ function updateMarkers(L?: any) {
markers.set(org.Id, marker) markers.set(org.Id, marker)
clusterGroup.addLayer(marker) clusterGroup.addLayer(marker)
}) })
// Bug E2E L3 : recadrer la carte sur les resultats filtres
if (orgsWithCoords.length > 0 && initialFitDone) {
try {
const bounds = leaflet.latLngBounds(
orgsWithCoords.map((o: any) => [o.latitude!, o.longitude!])
)
if (orgsWithCoords.length <= 15) {
mapInstance.fitBounds(bounds, {
padding: [40, 40],
maxZoom: 10,
animate: true,
})
}
} catch (e) {
console.warn('[NavMap] fitBounds echoue:', e)
}
}
initialFitDone = true
} }
// Réagir aux changements de filtres (liste d'orgs) // Réagir aux changements de filtres (liste d'orgs)

View File

@@ -136,18 +136,6 @@
</div> </div>
</div> </div>
<!-- CTA PROPOSER -->
<div
class="shrink-0 px-4 py-3 border-t"
style="border-color: var(--nav-bg-alt);"
>
<NuxtLink
to="/contribuer"
class="sidebar-cta-link"
>
+ Proposer une fiche
</NuxtLink>
</div>
</aside> </aside>
</template> </template>
@@ -266,24 +254,4 @@ function orgFonctions(org: Org): string[] {
color: var(--nav-text); color: var(--nav-text);
background: var(--nav-bg-alt); background: var(--nav-bg-alt);
} }
.sidebar-cta-link {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: center;
font-size: 0.82rem;
font-weight: 600;
color: var(--nav-primary-solid);
background: transparent;
border: 1px solid var(--nav-primary-solid);
border-radius: 6px;
text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.sidebar-cta-link:hover {
background: var(--nav-primary);
color: var(--nav-text-on-primary);
}
</style> </style>

View File

@@ -1,276 +0,0 @@
<template>
<div class="outremer-accordion">
<div
v-for="dom in DOM_TOM_PRATIQUES"
:key="dom.code"
class="outremer-item"
>
<button
class="outremer-header"
@click="toggle(dom.code)"
:aria-expanded="openDom === dom.code"
>
<span class="outremer-title">{{ dom.label }}</span>
<span class="outremer-meta">
<span class="outremer-count-badge" :style="orgCounts[dom.code] === 0 ? 'opacity:0.4' : ''">
{{ orgCounts[dom.code] ?? 0 }} fiche{{ (orgCounts[dom.code] ?? 0) > 1 ? 's' : '' }}
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
aria-hidden="true"
class="outremer-chevron"
:class="{ 'outremer-chevron--open': openDom === dom.code }"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
</button>
<div
v-show="openDom === dom.code"
class="outremer-map-container"
>
<div :ref="el => { if (el) mapRefs[dom.code] = el as HTMLElement }" class="outremer-map" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Map as LeafletMap, TileLayer } from 'leaflet'
interface Pratique {
id: number
nom: string
lat?: number | null
lng?: number | null
pays?: string
ville?: string
type?: string
score?: number
}
const DOM_TOM_PRATIQUES = [
{ code: 'GP', label: 'Guadeloupe', center: [16.25, -61.58] as [number, number], zoom: 9 },
{ code: 'MQ', label: 'Martinique', center: [14.65, -61.02] as [number, number], zoom: 9 },
{ code: 'GF', label: 'Guyane', center: [4.0, -53.0] as [number, number], zoom: 6 },
{ code: 'RE', label: 'La Réunion', center: [-21.11, 55.53] as [number, number], zoom: 9 },
{ code: 'YT', label: 'Mayotte', center: [-12.83, 45.16] as [number, number], zoom: 10 },
{ code: 'PF', label: 'Polynésie française', center: [-17.5, -149.5] as [number, number], zoom: 8 },
{ code: 'NC', label: 'Nouvelle-Calédonie', center: [-20.9, 165.6] as [number, number], zoom: 7 },
]
const props = defineProps<{
orgs: Pratique[]
selectedId?: number | null
}>()
const emit = defineEmits<{
'select-org': [id: number]
}>()
const mapRefs: Record<string, HTMLElement> = {}
const mapInstances: Record<string, LeafletMap> = {}
const tileLayers: Record<string, TileLayer> = {}
const openDom = ref<string | null>(null)
const orgCounts = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
DOM_TOM_PRATIQUES.forEach(d => { counts[d.code] = 0 })
props.orgs.forEach(o => {
if (o.pays && counts[o.pays] !== undefined) {
counts[o.pays]++
}
})
return counts
})
function toggle(code: string) {
openDom.value = openDom.value === code ? null : code
nextTick(() => {
if (openDom.value === code && !mapInstances[code]) {
initSingleMap(code)
} else if (openDom.value === code) {
mapInstances[code]?.invalidateSize()
}
})
}
function createPinIcon(L: any, score: number, isSelected = false) {
const bg = score >= 4 ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
const border = isSelected ? '#f5b342' : '#ffffff'
const size = isSelected ? 16 : 12
return L.divIcon({
className: '',
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${bg};border:2px solid ${border};"></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 4)],
})
}
function getTileUrl(dark: boolean) {
return dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
}
async function initSingleMap(code: string) {
const dom = DOM_TOM_PRATIQUES.find(d => d.code === code)
if (!dom) return
const Lmod = await import('leaflet')
const L: any = (Lmod as any).default || Lmod
await import('leaflet/dist/leaflet.css')
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const el = mapRefs[code]
if (!el) return
const map = L.map(el, {
center: dom.center, zoom: dom.zoom,
zoomControl: false, attributionControl: false,
dragging: true, scrollWheelZoom: true, doubleClickZoom: true,
touchZoom: true, keyboard: false,
})
const tileLayer = L.tileLayer(getTileUrl(isDark), {
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
})
tileLayer.addTo(map)
tileLayers[code] = tileLayer as unknown as TileLayer
mapInstances[code] = map as unknown as LeafletMap
renderPins(L, code)
}
function updateTheme(dark: boolean) {
const url = getTileUrl(dark)
Object.values(tileLayers).forEach(tl => {
(tl as any).setUrl(url)
})
}
function renderPins(L: any, code: string) {
const map = mapInstances[code] as any
if (!map) return
if (map._navMarkers) {
map._navMarkers.forEach((m: any) => m.remove())
}
map._navMarkers = []
const domOrgs = props.orgs.filter(o => o.pays === code && o.lat != null && o.lng != null)
domOrgs.forEach(org => {
const icon = createPinIcon(L, org.score ?? 1, org.id === props.selectedId)
const marker = L.marker([org.lat!, org.lng!], { icon })
marker.bindPopup(`
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:160px;padding:4px 0;">
<div style="font-weight:700;color:#1a2238;margin-bottom:2px;">${org.nom}</div>
${org.ville ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);">${org.ville}</div>` : ''}
${org.type ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);margin-top:2px;">${org.type}</div>` : ''}
<a href="/pratique/${org.id}" style="display:inline-block;margin-top:8px;font-size:12px;color:#1a2238;text-decoration:underline;">Voir la fiche →</a>
</div>
`, { maxWidth: 200 })
marker.on('click', () => emit('select-org', org.id))
marker.addTo(map)
map._navMarkers.push(marker)
})
}
watch(() => props.orgs, () => {
DOM_TOM_PRATIQUES.forEach(dom => {
if (mapInstances[dom.code]) {
import('leaflet').then(L => renderPins(L, dom.code))
}
})
}, { deep: false })
watch(() => props.selectedId, () => {
DOM_TOM_PRATIQUES.forEach(dom => {
if (mapInstances[dom.code]) {
import('leaflet').then(L => renderPins(L, dom.code))
}
})
})
let themeObserver: MutationObserver | null = null
onMounted(() => {
themeObserver = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark')
updateTheme(dark)
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
onUnmounted(() => {
themeObserver?.disconnect()
Object.values(mapInstances).forEach(m => (m as any).remove())
})
</script>
<style scoped>
.outremer-accordion {
display: flex;
flex-direction: column;
width: 100%;
}
.outremer-item {
border-bottom: 1px solid var(--nav-bg-alt);
}
.outremer-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
background: var(--nav-surface);
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.outremer-header:hover {
background: var(--nav-bg-alt);
}
.outremer-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
}
.outremer-meta {
display: flex;
align-items: center;
gap: 8px;
}
.outremer-count-badge {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.outremer-chevron {
color: var(--nav-text-muted);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.outremer-chevron--open {
transform: rotate(180deg);
}
.outremer-map-container {
height: 220px;
border-top: 1px solid var(--nav-bg-alt);
}
.outremer-map {
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,60 +0,0 @@
<template>
<div>
<!-- Groupe Europe -->
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">PAYS EUROPE</span>
<div class="flex flex-wrap gap-1 mb-2">
<button
v-for="code in EUROPE_CODES"
:key="code"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(code)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(code)"
>
{{ PAYS_LABELS[code] ?? code }}
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
</button>
</div>
<!-- Groupe Outre-mer -->
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">OUTRE-MER</span>
<div class="flex flex-wrap gap-1">
<button
v-for="code in OUTREMER_CODES"
:key="code"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(code)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(code)"
>
{{ PAYS_LABELS[code] ?? code }}
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { EUROPE_CODES, OUTREMER_CODES, PAYS_LABELS } from '~/types/pratique'
const props = defineProps<{
modelValue: string[]
counts?: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function toggle(code: string) {
if (props.modelValue.includes(code)) {
emit('update:modelValue', props.modelValue.filter(v => v !== code))
} else {
emit('update:modelValue', [...props.modelValue, code])
}
}
</script>

View File

@@ -1,274 +0,0 @@
<template>
<aside
class="flex flex-col h-full overflow-hidden"
style="background: var(--nav-surface); border-right: 1px solid var(--nav-bg-alt);"
>
<!-- BARRE DE RECHERCHE -->
<div
class="shrink-0 px-4 pt-4 pb-3 border-b"
style="border-color: var(--nav-bg-alt);"
>
<label class="sidebar-search-label" aria-label="Rechercher une pratique">
<svg
width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
class="sidebar-search-icon"
>
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref="searchInputEl"
:value="search"
type="search"
placeholder="Rechercher une pratique…"
class="sidebar-search-input"
autocomplete="off"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer la recherche"
@click.stop="emit('update:search', '')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- FILTRES -->
<div
class="shrink-0 px-4 pt-3 pb-3 space-y-4 border-b overflow-y-auto"
style="border-color: var(--nav-bg-alt); max-height: 280px;"
>
<!-- Critères régé -->
<CritereFilter
:modelValue="criteres"
:counts="critereCount"
@update:modelValue="emit('update:criteres', $event)"
/>
<!-- Type entité -->
<TypeEntiteFilter
:modelValue="typesEntite"
:counts="typeCount"
@update:modelValue="emit('update:typesEntite', $event)"
/>
</div>
<!-- LISTE FICHES -->
<div class="flex-1 flex flex-col min-h-0">
<div
class="shrink-0 flex items-center justify-between px-4 py-2 border-b"
style="border-color: var(--nav-bg-alt);"
>
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ resultCount }} résultat{{ resultCount > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="emit('reset-filters')"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>
Effacer les filtres
</button>
</div>
<div class="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
<div
v-if="pending"
class="flex items-center justify-center py-8"
style="color: var(--nav-text-muted);"
>
Chargement
</div>
<div v-else-if="pratiques.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<div
v-for="pratique in pratiques"
:key="pratique.id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === pratique.id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent); padding-left: 9px;'
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="emit('select-pratique', pratique.id)"
@mouseenter="emit('hover-pratique', pratique.id)"
@mouseleave="emit('hover-pratique', null)"
>
<div class="flex items-start justify-between gap-1.5">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ pratique.nom }}</span>
<span
v-if="pratique.pays"
class="shrink-0 px-1.5 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); margin-top: 1px;"
>{{ pratique.pays }}</span>
</div>
<div v-if="pratique.criteres?.length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="cId in pratique.criteres.slice(0, 3)"
:key="cId"
class="px-1.5 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ CRITERES.find(c => c.id === cId)?.label }}</span>
</div>
<div v-if="pratique.ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
{{ pratique.ville }}
</div>
</div>
</div>
</div>
<!-- CTA PROPOSER -->
<div
class="shrink-0 px-4 py-3 border-t"
style="border-color: var(--nav-bg-alt);"
>
<NuxtLink
to="/proposer-pratique"
class="sidebar-cta-link"
>
+ Proposer une pratique
</NuxtLink>
</div>
</aside>
</template>
<script setup lang="ts">
import { CRITERES } from '~/types/pratique'
interface Pratique {
id: number
nom: string
pays?: string
ville?: string
type?: string
criteres?: number[]
score?: number
}
const props = defineProps<{
search: string
criteres: number[]
typesEntite: string[]
critereCount: Record<number, number>
typeCount: Record<string, number>
resultCount: number
pratiques: Pratique[]
selectedId: number | null
hasActiveFilters: boolean
pending?: boolean
}>()
const emit = defineEmits<{
'update:search': [value: string]
'update:criteres': [value: number[]]
'update:typesEntite': [value: string[]]
'select-pratique': [id: number]
'hover-pratique': [id: number | null]
'reset-filters': []
}>()
const searchInputEl = ref<HTMLInputElement | null>(null)
</script>
<style scoped>
.sidebar-search-label {
display: flex;
align-items: center;
gap: 8px;
border: 1.5px solid var(--nav-bg-alt);
border-radius: 10px;
background: var(--nav-bg);
padding: 7px 10px;
cursor: text;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s;
}
.sidebar-search-label:focus-within {
border-color: var(--nav-primary);
background: var(--nav-surface);
}
.sidebar-search-icon {
color: var(--nav-text-muted);
flex-shrink: 0;
transition: color 0.2s;
}
.sidebar-search-label:focus-within .sidebar-search-icon {
color: var(--nav-primary-solid);
}
.sidebar-search-input {
border: none;
outline: none;
background: transparent;
color: var(--nav-text);
font-size: 13px;
width: 100%;
min-width: 0;
font-family: var(--nav-font);
}
.sidebar-search-input::placeholder {
color: var(--nav-text-muted);
}
.sidebar-search-input::-webkit-search-cancel-button {
display: none;
}
.sidebar-search-clear {
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted);
flex-shrink: 0;
padding: 2px;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
background: transparent;
border: none;
cursor: pointer;
}
.sidebar-search-clear:hover {
color: var(--nav-text);
background: var(--nav-bg-alt);
}
.sidebar-cta-link {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: center;
font-size: 0.82rem;
font-weight: 600;
color: var(--nav-primary-solid);
background: transparent;
border: 1px solid var(--nav-primary-solid);
border-radius: 6px;
text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.sidebar-cta-link:hover {
background: var(--nav-primary);
color: var(--nav-text-on-primary);
}
</style>

View File

@@ -1,41 +0,0 @@
<template>
<div>
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">TYPE D'ENTITÉ</span>
<div class="flex flex-wrap gap-1">
<button
v-for="type in TYPES_ENTITE"
:key="type"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(type)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(type)"
>
{{ TYPES_ENTITE_LABELS[type] ?? type }}
<span v-if="counts && counts[type] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[type] }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { TYPES_ENTITE, TYPES_ENTITE_LABELS } from '~/types/pratique'
const props = defineProps<{
modelValue: string[]
counts?: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function toggle(type: string) {
if (props.modelValue.includes(type)) {
emit('update:modelValue', props.modelValue.filter(v => v !== type))
} else {
emit('update:modelValue', [...props.modelValue, type])
}
}
</script>

View File

@@ -72,21 +72,6 @@ const { data: org, pending, error } = await useFetch<Org>(`/api/fiche/${orgId}`,
key: `fiche-${orgId}`, key: `fiche-${orgId}`,
}) })
// ── Fallback Pratiques regeneratives (bug E2E L1) ─────────────────────
// Si /api/fiche/:id echoue, on regarde si l'id correspond a une pratique
// regenerative et on redirige automatiquement vers /pratique/:id.
if (error.value) {
try {
const pratiquesRes = await $fetch<{ list: { id: number }[] }>('/api/pratiques')
const numericId = Number(orgId)
if (!isNaN(numericId) && pratiquesRes.list?.some((p) => p.id === numericId)) {
await navigateTo(`/pratique/${numericId}`, { replace: true })
}
} catch {
// pas de fallback dispo, on garde l'erreur
}
}
// ── Commentaires — tick de rafraîchissement ─────────────────────────── // ── Commentaires — tick de rafraîchissement ───────────────────────────
const commentRefreshTick = ref(0) const commentRefreshTick = ref(0)

View File

@@ -1,144 +1,56 @@
<template> <template>
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);"> <div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- SIDEBAR DESKTOP (>= 1024px) --> <!-- SIDEBAR DESKTOP ( 1024px) -->
<div class="hidden lg:block overflow-y-auto" style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;"> <div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
<NavSidebar
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) --> :search="search"
<IntentionBanner /> :modeValue="territoireMode"
:echelle="echelle"
<!-- Filtres familles + hashtags --> :fonctions="fonctions"
<HashtagFilter :territoire="territoire"
:allHashtags="allHashtags" :echelleCount="echelleCount"
:selectedHashtags="selectedHashtags" :fonctionCount="fonctionCount"
:selectedFamille="selectedFamille" :territoireCount="territoireCount"
@update:selectedHashtags="selectedHashtags = $event" :resultCount="filtered.length"
@update:selectedFamille="selectedFamille = $event" :orgs="filtered"
:selectedId="selectedId"
:hasActiveFilters="hasActiveFilters"
:pending="pending"
@update:search="onSearch"
@update:mode="onMode"
@update:echelle="onEchelle"
@update:fonctions="onFonctions"
@update:territoire="onTerritoire"
@select-org="onSelectOrg"
@hover-org="onHoverOrg"
@reset-filters="resetFilters"
/> />
<!-- Separateur -->
<div style="height: 1px; background: var(--nav-bg-alt);"></div>
<!-- Barre de recherche -->
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="sidebar-search-label" aria-label="Rechercher une structure">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-search-icon">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="search"
type="search"
placeholder="Rechercher une structure..."
class="sidebar-search-input"
autocomplete="off"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer"
@click.stop="search = ''"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- Header compteur + reset -->
<div class="flex items-center justify-between px-4 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>Effacer les filtres</button>
</div>
<!-- Liste fiches (sidebar entiere scroll - pas de scroll interne) -->
<div class="px-3 py-2 space-y-1.5">
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement...
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<div
v-for="structure in filtered"
:key="structure.id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === structure.id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)}; padding-left: 9px;`
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="onSelectStructure(structure.id)"
@mouseenter="hoveredId = structure.id"
@mouseleave="hoveredId = null"
>
<div class="flex items-start justify-between gap-1.5">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
<span
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
:style="`background: ${familleColor(structure.famille_principale)};`"
/>
</div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
<div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="tag in structure.hashtags.slice(0, 2)"
:key="tag"
class="text-xs"
style="color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
</div>
</div>
</div> </div>
<!-- ZONE CENTRALE (carte) --> <!-- ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative"> <main class="flex-1 flex flex-col overflow-hidden relative">
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── --> <!-- Indicateur source dev -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden"> <div
<!-- Onglets desktop --> v-if="dataSource === 'seed'"
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"> class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
<button style="background: var(--nav-accent); color: var(--nav-text);"
class="px-5 py-2 text-sm font-medium transition-colors" >
:style="desktopMapView === 'metropole' Mode dev données seed
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
>Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'graphe'"
>Vue graphique</button>
</div> </div>
<!-- Carte Métropole desktop --> <!-- VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas -->
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden"> <div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Carte Métropole pleine largeur -->
<div class="flex flex-col flex-1 overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;"> <div class="relative flex-1" style="min-height: 200px;">
<ClientOnly> <ClientOnly>
<NavMapV2 <NavMap
ref="navMapRef" ref="navMapRef"
:structures="metropoleStructures" :orgs="metropoleOrgs"
:selectedId="selectedId" :selectedId="selectedId"
@select-structure="onSelectStructure" @select-org="onSelectOrg"
/> />
<template #fallback> <template #fallback>
<div <div
@@ -150,53 +62,35 @@
</template> </template>
</ClientOnly> </ClientOnly>
</div> </div>
<ChatbotPlaceholder <ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div> </div>
<!-- Carte Outre-mer desktop --> <!-- Bandeau DOM-TOM row horizontale pleine largeur, hauteur fixe -->
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);"> <div
class="shrink-0"
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
>
<ClientOnly> <ClientOnly>
<OutremerMap <OutremerMap
:orgs="outremerOrgsLegacy" :orgs="outremerOrgs"
:selectedId="selectedIdLegacyNum" :selectedId="selectedId"
@select-org="() => {}" @select-org="onSelectOrg"
/> />
<template #fallback> <template #fallback>
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);"> <div
class="flex items-center justify-center h-full text-sm"
style="color: var(--nav-text-muted);"
>
Chargement Chargement
</div> </div>
</template> </template>
</ClientOnly> </ClientOnly>
</div> </div>
<!-- Vue graphique desktop -->
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
<div class="flex-1 overflow-hidden relative">
<ClientOnly>
<GraphView
:data="bifurcationData"
:allHashtags="allHashtags"
:active="desktopMapView === 'graphe'"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
Chargement du graphe...
</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
</div> </div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + sheet swipable ── --> <!-- VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable -->
<!-- Onglets Métropolitain / Outre-mer -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"> <div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button <button
class="flex-1 py-2 text-sm font-medium transition-colors" class="flex-1 py-2 text-sm font-medium transition-colors"
@@ -215,30 +109,34 @@
</div> </div>
<div class="lg:hidden flex-1 relative overflow-hidden"> <div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte mobile Métropole -->
<!-- Carte Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0"> <div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<ClientOnly> <ClientOnly>
<NavMapV2 <NavMap
ref="navMapMobileRef" ref="navMapMobileRef"
:structures="metropoleStructures" :orgs="metropoleOrgs"
:selectedId="selectedId" :selectedId="selectedId"
@select-structure="onSelectStructureMobile" @select-org="onSelectOrgMobile"
/> />
<template #fallback> <template #fallback>
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"> <div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
Chargement de la carte Chargement de la carte
</div> </div>
</template> </template>
</ClientOnly> </ClientOnly>
</div> </div>
<!-- Carte mobile Outre-mer --> <!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);"> <div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly> <ClientOnly>
<OutremerMap <OutremerMap
:orgs="outremerOrgsLegacy" :orgs="outremerOrgs"
:selectedId="selectedIdLegacyNum" :selectedId="selectedId"
@select-org="() => {}" @select-org="onSelectOrgMobile"
/> />
<template #fallback> <template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);"> <div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
@@ -248,65 +146,81 @@
</ClientOnly> </ClientOnly>
</div> </div>
<!-- Bottom sheet swipable --> <!-- Bottom sheet swipable (Métropole et Outre-mer) -->
<ClientOnly> <ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending"> <MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Bandeau intention mobile --> <!-- Barre recherche -->
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
<p class="text-xs leading-relaxed" style="color: var(--bifurc-banner-text, #2c2416); margin: 0;">
120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture.
</p>
</div>
<!-- Filtres hashtags mobile -->
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<HashtagFilter
:allHashtags="allHashtags"
:selectedHashtags="selectedHashtags"
:selectedFamille="selectedFamille"
@update:selectedHashtags="selectedHashtags = $event"
@update:selectedFamille="selectedFamille = $event"
/>
</div>
<!-- Barre recherche mobile -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);"> <div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une structure"> <label class="mobile-search-label" aria-label="Rechercher une organisation">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/> <circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/> <line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg> </svg>
<input <input
v-model="search" v-model="mobileSearch"
type="search" type="search"
placeholder="Rechercher…" placeholder="Rechercher…"
class="mobile-search-input" class="mobile-search-input"
autocomplete="off" autocomplete="off"
@input="onSearch(mobileSearch)"
/> />
<button <button
v-if="search" v-if="mobileSearch"
type="button" type="button"
class="mobile-search-clear" class="mobile-search-clear"
aria-label="Effacer" aria-label="Effacer"
@click.stop="search = ''" @click.stop="mobileSearch = ''; onSearch('')"
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg> </svg>
</button> </button>
</label> </label>
<!-- Filtres ÉCHELLE chips style FONCTION -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
<div class="flex flex-wrap gap-1">
<span
v-for="opt in ECHELLES"
:key="opt"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="echelle.includes(opt)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleEchelle(opt)"
>{{ opt }}</span>
</div>
</div>
<!-- Filtres FONCTION chips flex-wrap -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
<div class="flex flex-wrap gap-1">
<span
v-for="fn in FONCTIONS"
:key="fn"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="fonctions.includes(fn)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleFonction(fn)"
>{{ fn }}</span>
</div>
</div>
<button <button
v-if="hasActiveFilters" v-if="hasActiveFilters"
@click="resetFilters" @click="resetFilters"
class="mt-1 text-xs" class="mt-2 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;" style="color: var(--nav-text-muted); text-decoration: underline;"
>Effacer les filtres</button> > Effacer les filtres</button>
</div> </div>
<!-- Liste fiches mobile --> <!-- Compteur + Liste fiches -->
<div class="px-3 py-2"> <div class="px-3 py-2">
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);"> <div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }} {{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
</div> </div>
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);"> <div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement des fiches Chargement des fiches
@@ -319,36 +233,46 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div <div
v-for="structure in filtered" v-for="org in filtered"
:key="structure.id" :key="org.Id"
class="block rounded-lg p-3 transition-all cursor-pointer" class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === structure.id :style="selectedId === org.Id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};` ? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
: 'background: var(--nav-surface); border-left: 3px solid transparent;'" : 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectStructureMobile(structure.id)" @click="onSelectOrgMobile(org.Id)"
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span> <span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
<span <span
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1" v-if="org.echelle"
:style="`background: ${familleColor(structure.famille_principale)};`" class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
/> style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ org.echelle }}</span>
</div>
<div v-if="fonctionsList(org).length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="fn in fonctionsList(org)"
:key="fn"
class="px-1.5 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ fn }}</span>
</div>
<div v-if="org.localisation_ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
{{ org.localisation_ville }}
</div> </div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} · {{ structure.ville }}</div>
</div> </div>
</div> </div>
</div> </div>
</MobileSheet> </MobileSheet>
</ClientOnly> </ClientOnly>
</div> </div>
</main> </main>
<!-- MODAL FICHE V2 (desktop) --> <!-- MODAL FICHE (desktop) -->
<FicheModalV2 <FicheModal
v-model="ficheModalOpen" v-model="ficheModalOpen"
:structureId="ficheModalId" :orgId="ficheModalId"
:data="bifurcationData"
@update:structureId="ficheModalId = $event"
/> />
<!-- BOUTON CHATBOT FLOTTANT (mobile) --> <!-- BOUTON CHATBOT FLOTTANT (mobile) -->
@@ -377,141 +301,268 @@
<ChatbotSheet <ChatbotSheet
:modelValue="chatbotOpen" :modelValue="chatbotOpen"
@update:modelValue="chatbotOpen = $event" @update:modelValue="chatbotOpen = $event"
@highlightOrgs="() => {}" @highlightOrgs="onHighlightOrgs"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2' import type { Org } from '~/types/org'
// ── Couleurs familles ────────────────────────────────────────────────────── // ── URL query params sync ─────────────────────────────────────────────────
const FAMILLE_COLORS: Record<number, string> = { const route = useRoute()
1: '#a85d3e', const router = useRouter()
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
function familleColor(f: number): string { const search = ref<string>((route.query.q as string) ?? '')
return FAMILLE_COLORS[f] ?? '#888' const echelle = ref<string[]>(
} route.query.echelle
? (route.query.echelle as string).split(',').filter(Boolean)
: []
)
const fonctions = ref<string[]>(
route.query.fonctions
? (route.query.fonctions as string).split(',').filter(Boolean)
: []
)
const territoire = ref<string | null>((route.query.territoire as string) ?? null)
const territoireMode = ref<string>(
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
)
// ── État UI ──────────────────────────────────────────────────────────────── const selectedId = ref<number | null>(null)
const selectedId = ref<string | null>(null)
const hoveredId = ref<string | null>(null)
const ficheModalOpen = ref(false)
const ficheModalId = ref<string | null>(null)
const chatbotOpen = ref(false) const chatbotOpen = ref(false)
const ficheModalOpen = ref(false)
const ficheModalId = ref<number | null>(null)
const mobileMapView = ref<'metropole' | 'outremer'>('metropole') const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole') // Surlignage temporaire (5 sec) suite à une réponse chatbot
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
let highlightTimer: ReturnType<typeof setTimeout> | null = null
const prevSelectedId = ref<number | null>(null)
// Filtres function onHighlightOrgs(ids: (number | string)[]) {
const search = ref('') if (!ids.length) return
const selectedFamille = ref<number | null>(null) const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
const selectedHashtags = ref<string[]>([]) if (isNaN(firstId)) return
// Refs cartes // Sauvegarde la sélection courante
prevSelectedId.value = selectedId.value
selectedId.value = firstId
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
// Restaure la sélection précédente (ou null)
selectedId.value = prevSelectedId.value
prevSelectedId.value = null
highlightTimer = null
}, 5000)
}
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
const mobileSearch = ref<string>((route.query.q as string) ?? '')
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
const navMapRef = ref<any>(null) const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null) const navMapMobileRef = ref<any>(null)
// ── Données V2 - JSON statique ───────────────────────────────────────────── // Sync URL <-> état filtres
const bifurcationData = ref<ReseauxBifurcationData | null>(null) function syncUrl() {
const pending = ref(true) const q: Record<string, string> = {}
if (search.value) q.q = search.value
onMounted(async () => { if (echelle.value.length) q.echelle = echelle.value.join(',')
try { if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
bifurcationData.value = await $fetch<ReseauxBifurcationData>('/data/reseaux-bifurcation.json') if (territoire.value) q.territoire = territoire.value
} catch (e) { if (territoireMode.value === 'outremer') q.mode = 'outremer'
console.error('Erreur chargement reseaux-bifurcation.json', e) router.replace({ query: Object.keys(q).length ? q : undefined })
} finally {
pending.value = false
}
})
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
// Tous les hashtags uniques triés
const allHashtags = computed<string[]>(() => {
const set = new Set<string>()
structures.value.forEach(s => s.hashtags.forEach(h => set.add(h)))
return Array.from(set).sort()
})
// ── Filtrage ───────────────────────────────────────────────────────────────
const filtered = computed<StructureV2[]>(() => {
let result = structures.value
// Filtre texte
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
s =>
s.nom.toLowerCase().includes(q) ||
s.ville.toLowerCase().includes(q) ||
s.description_courte.toLowerCase().includes(q) ||
s.hashtags.some(h => h.toLowerCase().includes(q))
)
}
// Filtre famille - F6 = badge_f6_recherche_politique, pas famille_principale
if (selectedFamille.value !== null) {
if (selectedFamille.value === 6) {
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true)
} else {
result = result.filter(
s => s.famille_principale === selectedFamille.value ||
(s.familles_secondaires ?? []).includes(selectedFamille.value!)
)
}
}
// Filtre hashtags (AND logique si plusieurs)
if (selectedHashtags.value.length) {
result = result.filter(
s => selectedHashtags.value.every(h => s.hashtags.includes(h))
)
}
return result
})
const hasActiveFilters = computed(
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
)
function resetFilters() {
search.value = ''
selectedFamille.value = null
selectedHashtags.value = []
} }
// Structures métropole (pays != DOM-TOM, et avec coordonnées) // Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
// Pour simplifier : toutes les structures (la carte gère les sans-coords) function storeFiltersForBack() {
const metropoleStructures = computed<StructureV2[]>(() => filtered.value) if (typeof window === 'undefined') return
const q: Record<string, string> = {}
if (search.value) q.q = search.value
if (echelle.value.length) q.echelle = echelle.value.join(',')
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
if (territoire.value) q.territoire = territoire.value
if (territoireMode.value === 'outremer') q.mode = 'outremer'
const qs = new URLSearchParams(q).toString()
sessionStorage.setItem('nav_back_filters', qs)
}
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
// OutremerMap attend le format Org legacy - on passe un tableau vide function onMode(v: string) { territoireMode.value = v; syncUrl(); storeFiltersForBack() }
const outremerOrgsLegacy = computed(() => []) function onEchelle(v: string[]) { echelle.value = v; syncUrl(); storeFiltersForBack() }
const selectedIdLegacyNum = computed(() => null) function onFonctions(v: string[]) { fonctions.value = v; syncUrl(); storeFiltersForBack() }
function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); storeFiltersForBack() }
// ── Sélection ───────────────────────────────────────────────────────────── function onSelectOrg(id: number) {
function onSelectStructure(id: string) {
selectedId.value = selectedId.value === id ? null : id selectedId.value = selectedId.value === id ? null : id
// Desktop : ouvrir le modal fiche
if (typeof window !== 'undefined' && window.innerWidth >= 1024) { if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
ficheModalId.value = id ficheModalId.value = id
ficheModalOpen.value = true ficheModalOpen.value = true
} }
} }
function onSelectStructureMobile(id: string) { // Tap card mobile → ouvre la fiche détaillée
function onSelectOrgMobile(id: number) {
selectedId.value = id selectedId.value = id
ficheModalId.value = id storeFiltersForBack()
ficheModalOpen.value = true router.push(`/fiche/${id}`)
} }
useHead({ title: "AEP - Réseaux de bifurcation architecturale" }) function onHoverOrg(id: number | null) {
if (id !== null) selectedId.value = id
}
const hasActiveFilters = computed(() =>
!!search.value || echelle.value.length > 0 || fonctions.value.length > 0 || !!territoire.value
)
function resetFilters() {
search.value = ''
echelle.value = []
fonctions.value = []
territoire.value = null
router.replace({ query: undefined })
}
// Tagging compact mobile — toggle direct
function toggleEchelle(opt: string) {
if (echelle.value.includes(opt)) {
onEchelle(echelle.value.filter(v => v !== opt))
} else {
onEchelle([...echelle.value, opt])
}
}
function toggleFonction(fn: string) {
if (fonctions.value.includes(fn)) {
onFonctions(fonctions.value.filter(f => f !== fn))
} else {
onFonctions([...fonctions.value, fn])
}
}
// Sync recherche depuis app.vue top nav (via URL ?q=)
watch(() => route.query.q, (v) => {
search.value = (v as string) ?? ''
})
// ── Données ───────────────────────────────────────────────────────────────
const { data, pending, error } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
const orgs = computed<Org[]>(() => data.value?.list ?? [])
const dataSource = computed(() => data.value?.source ?? 'nocodb')
// Fiche aléatoire — réagit au ?random=1
watch(() => route.query.random, (v) => {
if (v === '1' && orgs.value.length > 0) {
const randomOrg = orgs.value[Math.floor(Math.random() * orgs.value.length)]
router.replace({ path: `/fiche/${randomOrg.Id}` })
}
})
// ── Filtrage côté client ──────────────────────────────────────────────────
const filtered = computed<Org[]>(() => {
let result = orgs.value
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
(o) =>
o.nom?.toLowerCase().includes(q) ||
o.localisation_ville?.toLowerCase().includes(q)
)
}
if (echelle.value.length) {
result = result.filter((o) => o.echelle && echelle.value.includes(o.echelle))
}
if (fonctions.value.length) {
// Garde les orgs qui matchent au moins 1 fonction sélectionnée
result = result.filter((o) => {
const orgFns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
return fonctions.value.some((fn) => orgFns.includes(fn))
})
// Tri par score pondéré : priorité 1 (1er cliqué) = poids le plus fort
const n = fonctions.value.length
const score = (o: Org) =>
fonctions.value.reduce((s, fn, i) => {
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
return s + (fns.includes(fn) ? (n - i) : 0)
}, 0)
result = [...result].sort((a, b) => score(b) - score(a))
}
if (territoire.value) {
result = result.filter((o) => o.territoire === territoire.value)
}
return result
})
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const DOM_TOM_LIST = DOM_TOM
const metropoleOrgs = computed<Org[]>(() =>
filtered.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire))
)
const outremerOrgs = computed<Org[]>(() => {
if (territoire.value && DOM_TOM.includes(territoire.value)) {
return filtered.value.filter(o => o.territoire === territoire.value)
}
return filtered.value.filter(o => o.territoire && DOM_TOM.includes(o.territoire))
})
const outremerCountByDom = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
DOM_TOM.forEach(d => { counts[d] = 0 })
filtered.value.forEach(o => {
if (o.territoire && DOM_TOM.includes(o.territoire)) {
counts[o.territoire] = (counts[o.territoire] ?? 0) + 1
}
})
return counts
})
// ── Compteurs ─────────────────────────────────────────────────────────────
const ECHELLES = ['National', 'Régional', 'Local'] as const
const ECHELLE_LABELS: Record<string, string> = { National: 'Nat', Régional: 'Rég', Local: 'Loc' }
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale'] as const
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const echelleCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
ECHELLES.forEach((e) => { counts[e] = 0 })
orgs.value.forEach((o) => { if (o.echelle) counts[o.echelle] = (counts[o.echelle] ?? 0) + 1 })
return counts
})
const fonctionCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
FONCTIONS.forEach((f) => { counts[f] = 0 })
orgs.value.forEach((o) => {
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
fns.forEach((fn) => { counts[fn] = (counts[fn] ?? 0) + 1 })
})
return counts
})
const territoireCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
TERRITOIRES.forEach((t) => { counts[t] = 0 })
orgs.value.forEach((o) => { if (o.territoire) counts[o.territoire] = (counts[o.territoire] ?? 0) + 1 })
counts['Métropole'] = orgs.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire)).length
return counts
})
// ── Helpers ───────────────────────────────────────────────────────────────
function fonctionsList(org: Org): string[] {
return (org.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean).slice(0, 3)
}
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
</script> </script>

View File

@@ -1,170 +0,0 @@
<template>
<div class="min-h-screen" style="background: var(--nav-bg);">
<div class="max-w-4xl mx-auto px-4 py-6">
<!-- Bouton retour carte (préserve filtres URL) -->
<NuxtLink
:to="retourUrl"
class="inline-flex items-center gap-1.5 text-sm mb-6 rounded-lg px-3 py-1.5 transition-colors"
style="color: var(--nav-text); background: var(--nav-bg-alt);"
aria-label="Retour aux pratiques régénératives"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Retour aux pratiques régénératives
</NuxtLink>
<!-- Chargement -->
<div v-if="pending" class="py-16 text-center text-sm" style="color: var(--nav-text-muted);">
Chargement de la fiche
</div>
<!-- Erreur -->
<div v-else-if="!pratique" class="py-16 text-center">
<p class="text-lg font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
<p class="text-sm" style="color: var(--nav-text-muted);">La pratique demandée n'existe pas ou a été supprimée.</p>
</div>
<!-- ── Contenu ─────────────────────────────────────── -->
<template v-else>
<!-- Header fiche -->
<div class="mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
<h1 class="text-2xl font-bold leading-tight" style="color: var(--nav-text);">{{ pratique.nom }}</h1>
<div class="flex items-center gap-2 shrink-0">
<span
class="px-2 py-1 rounded-full text-xs font-semibold uppercase tracking-wide"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ TYPES_ENTITE_LABELS[pratique.type] ?? pratique.type }}</span>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap mb-3">
<span class="text-sm font-medium" style="color: var(--nav-text-muted);">
{{ PAYS_LABELS[pratique.pays] ?? pratique.pays }}
<template v-if="pratique.ville"> · {{ pratique.ville }}</template>
</span>
<span v-if="pratique.score" class="px-2 py-0.5 rounded text-xs" style="background: var(--nav-accent); color: var(--nav-text);">
Score {{ pratique.score }}/5
</span>
<a
v-if="pratique.url"
:href="pratique.url"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline"
style="color: var(--nav-primary-solid);"
>Site web →</a>
</div>
<!-- Description -->
<p v-if="pratique.description" class="text-sm leading-relaxed" style="color: var(--nav-text);">
{{ pratique.description }}
</p>
</div>
<!-- Séparateur -->
<div class="mb-6" style="height: 1px; background: var(--nav-bg-alt);"></div>
<!-- Critères régénératifs -->
<div v-if="pratique.criteres?.length" class="mb-6">
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Critères régénératifs</h2>
<div class="flex flex-wrap gap-2">
<span
v-for="cId in pratique.criteres"
:key="cId"
class="px-3 py-1 rounded-full text-sm font-medium"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
>
{{ CRITERES.find(c => c.id === cId)?.label ?? `Critère ${cId}` }}
</span>
</div>
</div>
<!-- Tags -->
<div v-if="pratique.tags?.length" class="mb-6">
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Tags</h2>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in pratique.tags"
:key="tag"
class="px-2 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
</div>
<!-- Métadonnées -->
<div class="mb-6">
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Informations</h2>
<dl class="space-y-1.5">
<div v-if="pratique.passe" class="flex gap-2 text-sm">
<dt style="color: var(--nav-text-muted);">Passe :</dt>
<dd style="color: var(--nav-text);">{{ pratique.passe }}</dd>
</div>
<div v-if="pratique.source" class="flex gap-2 text-sm">
<dt style="color: var(--nav-text-muted);">Source :</dt>
<dd style="color: var(--nav-text);">{{ pratique.source }}</dd>
</div>
<div v-if="pratique.lat != null && pratique.lng != null" class="flex gap-2 text-sm">
<dt style="color: var(--nav-text-muted);">Coordonnées :</dt>
<dd style="color: var(--nav-text);">{{ pratique.lat }}, {{ pratique.lng }}</dd>
</div>
</dl>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { Pratique } from '~/types/pratique'
import { CRITERES, TYPES_ENTITE_LABELS, PAYS_LABELS } from '~/types/pratique'
// ── Params & route ────────────────────────────────────────────────────
const route = useRoute()
const pratiqueId = route.params.id as string
// ── Retour carte — préserve les filtres via sessionStorage ────────────
const retourUrl = ref('/pratiques-regeneratives')
onMounted(() => {
if (typeof window !== 'undefined') {
const stored = sessionStorage.getItem('pratiques_back_filters')
if (stored) {
retourUrl.value = `/pratiques-regeneratives?${stored}`
}
}
})
// ── Fetch toutes les pratiques et trouver la bonne ───────────────────
const { data, pending } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques', {
key: `pratiques-all`,
})
const pratique = computed<Pratique | null>(() => {
const id = parseInt(pratiqueId, 10)
if (isNaN(id)) return null
return data.value?.list?.find(p => p.id === id) ?? null
})
// ── SEO dynamiques ────────────────────────────────────────────────────
useHead({
title: computed(() =>
pratique.value ? `${pratique.value.nom} — Pratiques régénératives — AEP` : 'Pratique régénérative — AEP'
),
meta: [
{
name: 'description',
content: computed(() =>
pratique.value?.description?.substring(0, 160).trim() ?? 'Pratique régénérative — AEP'
),
},
],
})
</script>

View File

@@ -1,564 +0,0 @@
<template>
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- SIDEBAR DESKTOP ( 1024px) -->
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
<PratiqueSidebar
:search="search"
:criteres="criteres"
:typesEntite="typesEntite"
:critereCount="critereCount"
:typeCount="typeCount"
:resultCount="filtered.length"
:pratiques="filtered"
:selectedId="selectedId"
:hasActiveFilters="hasActiveFilters"
:pending="pending"
@update:search="onSearch"
@update:criteres="onCriteres"
@update:typesEntite="onTypesEntite"
@select-pratique="onSelectPratique"
@hover-pratique="onHoverPratique"
@reset-filters="resetFilters"
/>
</div>
<!-- ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- VUE DESKTOP : Onglets Europe / Outre-mer + carte pleine hauteur -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Onglets Europe / Outre-mer (desktop) -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
type="button"
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'europe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'europe'"
>
Europe
<span class="ml-1 text-xs opacity-70">({{ europeOrgs.length }})</span>
</button>
<button
type="button"
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
>
Outre-mer
<span class="ml-1 text-xs opacity-70">({{ outremerOrgs.length }})</span>
</button>
</div>
<!-- Carte Europe (pleine hauteur) -->
<div v-show="desktopMapView === 'europe'" class="flex flex-col flex-1 overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;">
<ClientOnly>
<EuropeMap
ref="europeMapRef"
:orgs="europeOrgs"
:selectedId="selectedId"
@select-org="onSelectPratique"
/>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
Chargement de la carte
</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder
endpoint="/api/chatbot-pratiques"
title="Chatbot Pratiques régé"
placeholder="Pose une question sur les pratiques régénératives…"
ficheBasePath="/pratique"
@highlightOrgs="onHighlightOrgs"
>
<template #onboarding>
<p>Ce chatbot interroge la base des pratiques régénératives
(Mistral FR, serveur européen souverain, zéro rétention).</p>
<p>Pour t'aider à trouver les pratiques pertinentes,
formule ta requête ainsi :</p>
<ul>
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
<li>• Lieu : [pays ou région]</li>
</ul>
<p class="example">Exemple : "Je cherche une coopérative qui travaille
le réemploi de matériaux en Belgique."</p>
</template>
</ChatbotPlaceholder>
</div>
<!-- Carte Outre-mer (pleine hauteur, scroll) -->
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMapPratiques
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectPratique"
/>
<template #fallback>
<div
class="flex items-center justify-center h-48 text-sm"
style="color: var(--nav-text-muted);"
>
Chargement…
</div>
</template>
</ClientOnly>
</div>
</div>
<!-- ── VUE MOBILE : Onglets Europe/Outre-mer + carte pleine hauteur + sheet swipable ── -->
<!-- Onglets Europe / Outre-mer -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'europe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'europe'"
>Europe</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'"
>Outre-mer</button>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte Europe -->
<div v-show="mobileMapView === 'europe'" class="absolute inset-0">
<ClientOnly>
<EuropeMap
ref="europeMapMobileRef"
:orgs="europeOrgs"
:selectedId="selectedId"
@select-org="onSelectPratiqueMobile"
/>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
Chargement de la carte…
</div>
</template>
</ClientOnly>
</div>
<!-- Carte Outre-mer -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMapPratiques
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectPratiqueMobile"
/>
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement…
</div>
</template>
</ClientOnly>
</div>
<!-- Bottom sheet swipable (Europe et Outre-mer) -->
<ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Barre recherche -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une pratique">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="mobileSearch"
type="search"
placeholder="Rechercher…"
class="mobile-search-input"
autocomplete="off"
@input="onSearch(mobileSearch)"
/>
<button
v-if="mobileSearch"
type="button"
class="mobile-search-clear"
aria-label="Effacer"
@click.stop="mobileSearch = ''; onSearch('')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
<!-- Filtres CRITÈRES — chips -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">CRITÈRES</span>
<div class="flex flex-wrap gap-1">
<button
v-for="c in CRITERES"
:key="c.id"
type="button"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="criteres.includes(c.id)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
:aria-pressed="criteres.includes(c.id)"
@click="toggleCritere(c.id)"
>{{ c.label }}</button>
</div>
</div>
<!-- Filtres TYPE — chips -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">TYPE</span>
<div class="flex flex-wrap gap-1">
<button
v-for="t in TYPES_ENTITE"
:key="t"
type="button"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="typesEntite.includes(t)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
:aria-pressed="typesEntite.includes(t)"
@click="toggleType(t)"
>{{ TYPES_ENTITE_LABELS[t] ?? t }}</button>
</div>
</div>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="mt-2 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;"
>Effacer les filtres</button>
</div>
<!-- Compteur + Liste fiches -->
<div class="px-3 py-2">
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
</div>
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement des fiches…
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
Effacer les filtres
</button>
</div>
<div class="space-y-2">
<div
v-for="pratique in filtered"
:key="pratique.id"
class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === pratique.id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectPratiqueMobile(pratique.id)"
>
<div class="flex items-start justify-between gap-2">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ pratique.nom }}</span>
<span
v-if="pratique.pays"
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ pratique.pays }}</span>
</div>
<div v-if="pratique.criteres?.length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="cId in pratique.criteres.slice(0, 3)"
:key="cId"
class="px-1.5 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ CRITERES.find(c => c.id === cId)?.label }}</span>
</div>
<div v-if="pratique.ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
{{ pratique.ville }}
</div>
</div>
</div>
</div>
</MobileSheet>
</ClientOnly>
</div>
</main>
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
<button
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
style="
height: 48px;
background: var(--nav-primary);
opacity: 0.92;
color: var(--nav-text-on-primary);
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
font-family: var(--nav-font);
font-size: 0.875rem;
font-weight: 600;
"
aria-label="Ouvrir l'assistant Chatbot"
@click="chatbotOpen = true"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Chatbot</span>
</button>
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
<ChatbotSheet
:modelValue="chatbotOpen"
endpoint="/api/chatbot-pratiques"
title="Chatbot Pratiques régé"
ficheBasePath="/pratique"
@update:modelValue="chatbotOpen = $event"
@highlightOrgs="onHighlightOrgs"
>
<template #onboarding>
<p>Ce chatbot interroge la base des pratiques régénératives
(Mistral FR, serveur européen souverain, zéro rétention).</p>
<p>Pour t'aider à trouver les pratiques pertinentes,
formule ta requête ainsi :</p>
<ul>
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
<li>• Lieu : [pays ou région]</li>
</ul>
<p class="example">Exemple : "Je cherche une coopérative qui travaille
le réemploi de matériaux en Belgique."</p>
</template>
</ChatbotSheet>
</div>
</template>
<script setup lang="ts">
import type { Pratique } from '~/types/pratique'
import { CRITERES, TYPES_ENTITE, TYPES_ENTITE_LABELS, EUROPE_CODES, OUTREMER_CODES } from '~/types/pratique'
// ── URL query params sync ─────────────────────────────────────────────────
const route = useRoute()
const router = useRouter()
const search = ref<string>((route.query.q as string) ?? '')
const criteres = ref<number[]>(
route.query.criteres
? (route.query.criteres as string).split(',').map(Number).filter(Boolean)
: []
)
const typesEntite = ref<string[]>(
route.query.types
? (route.query.types as string).split(',').filter(Boolean)
: []
)
const pays = ref<string[]>(
route.query.pays
? (route.query.pays as string).split(',').filter(Boolean)
: []
)
const selectedId = ref<number | null>(null)
const mobileMapView = ref<'europe' | 'outremer'>('europe')
const desktopMapView = ref<'europe' | 'outremer'>('europe')
const chatbotOpen = ref(false)
// Surlignage temporaire (5 sec) suite a une reponse chatbot
let highlightTimer: ReturnType<typeof setTimeout> | null = null
const prevSelectedId = ref<number | null>(null)
function onHighlightOrgs(ids: (number | string)[]) {
if (!ids.length) return
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
if (isNaN(firstId)) return
prevSelectedId.value = selectedId.value
selectedId.value = firstId
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
selectedId.value = prevSelectedId.value
prevSelectedId.value = null
highlightTimer = null
}, 5000)
}
// Refs vers les instances EuropeMap
const europeMapRef = ref<any>(null)
const europeMapMobileRef = ref<any>(null)
// Ref locale barre de recherche mobile
const mobileSearch = ref<string>((route.query.q as string) ?? '')
// Sync URL <-> état filtres
function syncUrl() {
const q: Record<string, string> = {}
if (search.value) q.q = search.value
if (criteres.value.length) q.criteres = criteres.value.join(',')
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
if (pays.value.length) q.pays = pays.value.join(',')
router.replace({ query: Object.keys(q).length ? q : undefined })
}
// Sauvegarde filtres pour bouton retour des fiches
function storeFiltersForBack() {
if (typeof window === 'undefined') return
const q: Record<string, string> = {}
if (search.value) q.q = search.value
if (criteres.value.length) q.criteres = criteres.value.join(',')
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
if (pays.value.length) q.pays = pays.value.join(',')
const qs = new URLSearchParams(q).toString()
sessionStorage.setItem('pratiques_back_filters', qs)
}
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
function onCriteres(v: number[]) { criteres.value = v; syncUrl(); storeFiltersForBack() }
function onTypesEntite(v: string[]) { typesEntite.value = v; syncUrl(); storeFiltersForBack() }
function onPays(v: string[]) { pays.value = v; syncUrl(); storeFiltersForBack() }
function onSelectPratique(id: number) {
selectedId.value = selectedId.value === id ? null : id
// Desktop : naviguer vers la fiche
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
storeFiltersForBack()
router.push(`/pratique/${id}`)
}
}
function onSelectPratiqueMobile(id: number) {
selectedId.value = id
storeFiltersForBack()
router.push(`/pratique/${id}`)
}
function onHoverPratique(id: number | null) {
if (id !== null) selectedId.value = id
}
const hasActiveFilters = computed(() =>
!!search.value || criteres.value.length > 0 || typesEntite.value.length > 0 || pays.value.length > 0
)
function resetFilters() {
search.value = ''
mobileSearch.value = ''
criteres.value = []
typesEntite.value = []
pays.value = []
router.replace({ query: undefined })
}
function toggleCritere(id: number) {
if (criteres.value.includes(id)) {
onCriteres(criteres.value.filter(v => v !== id))
} else {
onCriteres([...criteres.value, id])
}
}
function toggleType(t: string) {
if (typesEntite.value.includes(t)) {
onTypesEntite(typesEntite.value.filter(v => v !== t))
} else {
onTypesEntite([...typesEntite.value, t])
}
}
// Sync recherche depuis URL ?q=
watch(() => route.query.q, (v) => {
search.value = (v as string) ?? ''
})
// ── Données ───────────────────────────────────────────────────────────────
const { data, pending, error: fetchError } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques')
const pratiques = computed<Pratique[]>(() => data.value?.list ?? [])
// ── Filtrage côté client ──────────────────────────────────────────────────
const filtered = computed<Pratique[]>(() => {
let result = pratiques.value
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
(o) =>
o.nom?.toLowerCase().includes(q) ||
o.ville?.toLowerCase().includes(q) ||
o.description?.toLowerCase().includes(q)
)
}
if (criteres.value.length) {
result = result.filter((o) =>
criteres.value.some((cId) => o.criteres?.includes(cId))
)
// Tri par score pondéré : priorité au premier critère cliqué
const n = criteres.value.length
const score = (o: Pratique) =>
criteres.value.reduce((s, cId, i) => {
return s + (o.criteres?.includes(cId) ? (n - i) : 0)
}, 0)
result = [...result].sort((a, b) => score(b) - score(a))
}
if (typesEntite.value.length) {
result = result.filter((o) => o.type && typesEntite.value.includes(o.type))
}
if (pays.value.length) {
result = result.filter((o) => o.pays && pays.value.includes(o.pays))
}
return result
})
// Séparation Europe / Outre-mer
const europeOrgs = computed<Pratique[]>(() =>
filtered.value.filter(o => !o.pays || (EUROPE_CODES as readonly string[]).includes(o.pays))
)
const outremerOrgs = computed<Pratique[]>(() =>
filtered.value.filter(o => o.pays && (OUTREMER_CODES as readonly string[]).includes(o.pays))
)
// ── Compteurs ─────────────────────────────────────────────────────────────
const critereCount = computed<Record<number, number>>(() => {
const counts: Record<number, number> = {}
CRITERES.forEach(c => { counts[c.id] = 0 })
pratiques.value.forEach(o => {
o.criteres?.forEach(cId => { counts[cId] = (counts[cId] ?? 0) + 1 })
})
return counts
})
const typeCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
TYPES_ENTITE.forEach(t => { counts[t] = 0 })
pratiques.value.forEach(o => {
if (o.type) counts[o.type] = (counts[o.type] ?? 0) + 1
})
return counts
})
useHead({ title: 'AEP — Pratiques régénératives en Europe' })
</script>

View File

@@ -1,833 +0,0 @@
<template>
<div class="contribuer-page">
<div class="contribuer-inner">
<!-- Retour -->
<NuxtLink to="/pratiques-regeneratives" class="back-link">
Retour aux pratiques régénératives
</NuxtLink>
<!-- En-tête -->
<div class="contribuer-header">
<h1>Proposer une pratique</h1>
<p class="contribuer-subtitle">
Tu connais une agence, un collectif ou un réseau qui incarne l'architecture régénérative ?
Soumets-le ici — Jules valide manuellement les nouvelles entrées.
</p>
<p class="contribuer-hint">
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien.
Mais une description de ta main, c'est toujours plus vivant.
</p>
</div>
<!-- Message succès -->
<div v-if="success" class="success-block" role="status" aria-live="polite">
<div class="success-icon">✓</div>
<h2>Merci !</h2>
<p>Ta proposition est en attente de modération.</p>
<p class="success-detail">
Jules valide manuellement chaque entrée avant publication.
</p>
<button type="button" class="btn-secondary" @click="reset">
Proposer une autre pratique
</button>
</div>
<!-- Formulaire -->
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
<!-- Nom -->
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
<input
id="nom"
v-model="form.nom"
type="text"
placeholder="Ex : Lacaton & Vassal, Plateau Urbain..."
autocomplete="organization"
@blur="validateField('nom')"
/>
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<!-- URL -->
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="url">
Site web
<span class="label-hint">(optionnel recommandé)</span>
</label>
<input
id="url"
v-model="form.url"
type="url"
placeholder="https://..."
@blur="validateField('url')"
/>
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<!-- Description -->
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="description_user">
Description courte <span class="required">*</span>
<span class="label-hint">(50 à 500 caractères)</span>
</label>
<textarea
id="description_user"
v-model="form.description_user"
rows="4"
placeholder="Décris cette pratique : approche, matériaux, posture, ce qui la rend régénérative..."
@blur="validateField('description_user')"
/>
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">
{{ errors.description_user }}
</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">
{{ form.description_user.length }}/500
</span>
</div>
</div>
<!-- Critères régénératifs -->
<div class="field-group" :class="{ 'field-error': errors.criteres }">
<fieldset>
<legend>
Critères régénératifs <span class="required">*</span>
<span class="label-hint">(3 minimum, 8 maximum)</span>
</legend>
<div class="checkbox-grid">
<label
v-for="c in CRITERES"
:key="c.id"
class="checkbox-label"
:class="{
active: form.criteres.includes(c.id),
disabled: !form.criteres.includes(c.id) && form.criteres.length >= 8,
}"
>
<input
type="checkbox"
:value="c.id"
:checked="form.criteres.includes(c.id)"
:disabled="!form.criteres.includes(c.id) && form.criteres.length >= 8"
@change="toggleCritere(c.id)"
/>
{{ c.label }}
</label>
</div>
</fieldset>
<span v-if="errors.criteres" class="error-msg" role="alert">{{ errors.criteres }}</span>
</div>
<!-- Type d'entité -->
<div class="field-group" :class="{ 'field-error': errors.type }">
<fieldset>
<legend>
Type d'entité <span class="required">*</span>
</legend>
<div class="radio-group">
<label
v-for="t in TYPES_ENTITE"
:key="t"
class="radio-label"
:class="{ active: form.type === t }"
>
<input
type="radio"
:value="t"
v-model="form.type"
name="type"
@change="validateField('type')"
/>
{{ TYPES_ENTITE_LABELS[t] }}
</label>
</div>
</fieldset>
<span v-if="errors.type" class="error-msg" role="alert">{{ errors.type }}</span>
</div>
<!-- Pays -->
<div class="field-group" :class="{ 'field-error': errors.pays }">
<label for="pays">
Pays <span class="required">*</span>
</label>
<select
id="pays"
v-model="form.pays"
@change="validateField('pays')"
>
<option value="" disabled>Sélectionne un pays...</option>
<optgroup label="Europe">
<option v-for="code in EUROPE_CODES" :key="code" :value="code">
{{ PAYS_LABELS[code] }}
</option>
</optgroup>
<optgroup label="DOM-TOM">
<option v-for="code in OUTREMER_CODES" :key="code" :value="code">
{{ PAYS_LABELS[code] }}
</option>
</optgroup>
<optgroup label="Autre">
<option value="AUTRE">Autre pays...</option>
</optgroup>
</select>
<span v-if="errors.pays" class="error-msg" role="alert">{{ errors.pays }}</span>
</div>
<!-- Pays autre (conditionnel) -->
<div v-if="form.pays === 'AUTRE'" class="field-group" :class="{ 'field-error': errors.pays_autre }">
<label for="pays_autre">Précise le pays</label>
<input
id="pays_autre"
v-model="form.pays_autre"
type="text"
placeholder="Ex : Maroc, Brésil..."
maxlength="50"
/>
<span v-if="errors.pays_autre" class="error-msg" role="alert">{{ errors.pays_autre }}</span>
</div>
<!-- Ville -->
<div class="field-group" :class="{ 'field-error': errors.ville }">
<label for="ville">
Ville principale
<span class="label-hint">(optionnel)</span>
</label>
<input
id="ville"
v-model="form.ville"
type="text"
placeholder="Ex : Paris, Bordeaux, Bruxelles..."
/>
<span v-if="errors.ville" class="error-msg" role="alert">{{ errors.ville }}</span>
</div>
<!-- Tags -->
<div class="field-group" :class="{ 'field-error': errors.tags }">
<label for="tags">
Tags
<span class="label-hint">(optionnel — 3 à 6 mots-clés, séparés par des virgules)</span>
</label>
<input
id="tags"
v-model="tagsInput"
type="text"
placeholder="Ex : biosourcé, réhabilitation, circuit-court"
@blur="parseTags"
/>
<span v-if="errors.tags" class="error-msg" role="alert">{{ errors.tags }}</span>
<div v-if="form.tags && form.tags.length" class="tags-preview">
<span v-for="tag in form.tags" :key="tag" class="tag-chip">{{ tag }}</span>
</div>
</div>
<!-- Email -->
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="submitted_by_email">
Ton email
<span class="label-hint">(optionnel — pour le suivi)</span>
</label>
<input
id="submitted_by_email"
v-model="form.submitted_by_email"
type="email"
placeholder="ton@email.fr"
autocomplete="email"
@blur="validateField('submitted_by_email')"
/>
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">
{{ errors.submitted_by_email }}
</span>
</div>
<!-- Erreur globale -->
<div v-if="serverError" class="server-error" role="alert">
<strong>Erreur :</strong> {{ serverError }}
</div>
<!-- Actions -->
<div class="form-actions">
<NuxtLink to="/pratiques-regeneratives" class="btn-secondary">Annuler</NuxtLink>
<button
type="submit"
class="btn-primary"
:disabled="submitting"
>
{{ submitting ? 'Envoi en cours...' : 'Proposer la pratique ' }}
</button>
</div>
<p class="form-note">
Ta proposition sera examinée par Jules avant publication.
</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { z } from 'zod'
import { CRITERES, TYPES_ENTITE, TYPES_ENTITE_LABELS, EUROPE_CODES, OUTREMER_CODES, PAYS_LABELS } from '~/types/pratique'
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
const PratiqueSubmitSchema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
criteres: z
.array(z.number().int().min(1).max(8))
.min(3, 'Sélectionne au moins 3 critères')
.max(8, 'Maximum 8 critères'),
pays: z.string().length(2, 'Sélectionne un pays').or(z.literal('AUTRE')),
pays_autre: z.string().max(50).optional(),
ville: z.string().max(100).optional(),
type: z.enum(TYPES_ENTITE, { errorMap: () => ({ message: 'Sélectionne un type d\'entité' }) }),
tags: z.array(z.string().max(30)).max(6).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
// ── État du formulaire ────────────────────────────────────────────────────────
const form = reactive({
nom: '',
url: '',
description_user: '',
criteres: [] as number[],
pays: '' as string,
pays_autre: '',
ville: '',
type: '' as typeof TYPES_ENTITE[number] | '',
tags: [] as string[],
submitted_by_email: '',
})
const tagsInput = ref('')
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const success = ref(false)
const serverError = ref('')
// ── Validation champ par champ ────────────────────────────────────────────────
function validateField(field: string) {
const partial = PratiqueSubmitSchema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = PratiqueSubmitSchema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(
Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])
))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
// ── Gestion critères ──────────────────────────────────────────────────────────
function toggleCritere(id: number) {
const idx = form.criteres.indexOf(id)
if (idx >= 0) {
form.criteres.splice(idx, 1)
} else if (form.criteres.length < 8) {
form.criteres.push(id)
}
validateField('criteres')
}
// ── Gestion tags ──────────────────────────────────────────────────────────────
function parseTags() {
const raw = tagsInput.value
.split(',')
.map(t => t.trim().toLowerCase())
.filter(t => t.length > 0 && t.length <= 30)
.slice(0, 6)
form.tags = raw
}
// ── Soumission ────────────────────────────────────────────────────────────────
async function submit() {
serverError.value = ''
parseTags()
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit-pratique', {
method: 'POST',
body: {
nom: form.nom,
url: form.url || undefined,
description_user: form.description_user,
criteres: form.criteres,
pays: form.pays,
pays_autre: form.pays_autre || undefined,
ville: form.ville || undefined,
type: form.type,
tags: form.tags.length ? form.tags : undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
success.value = true
} catch (e: any) {
const status = e?.status ?? e?.statusCode
if (status === 429) {
serverError.value = 'Tu as déjà soumis 3 pratiques aujourd\'hui. Réessaie demain.'
} else if (status === 422 && e?.data) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => {
errors[k] = Array.isArray(v) ? v[0] : String(v)
})
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
function reset() {
Object.assign(form, {
nom: '', url: '', description_user: '', criteres: [],
pays: '', pays_autre: '', ville: '', type: '', tags: [], submitted_by_email: '',
})
tagsInput.value = ''
Object.keys(errors).forEach(k => delete errors[k])
success.value = false
serverError.value = ''
}
// ── Meta ──────────────────────────────────────────────────────────────────────
useHead({ title: 'Proposer une pratique — AEP' })
</script>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.contribuer-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 4rem;
}
.contribuer-inner {
max-width: 640px;
margin: 0 auto;
}
/* ── Retour ──────────────────────────────────────────────────────────────────── */
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--nav-primary-solid);
opacity: 0.7;
text-decoration: none;
margin-bottom: 1.5rem;
transition: opacity 0.15s;
}
.back-link:hover {
opacity: 1;
}
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
.contribuer-header {
margin-bottom: 2rem;
}
.contribuer-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.contribuer-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted);
line-height: 1.5;
margin: 0 0 0.5rem;
}
.contribuer-hint {
font-size: 0.82rem;
color: var(--nav-text-muted);
opacity: 0.75;
line-height: 1.5;
margin: 0;
}
/* ── Succès ──────────────────────────────────────────────────────────────────── */
.success-block {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 12px;
padding: 2rem 1.5rem;
text-align: center;
}
.success-icon {
width: 48px;
height: 48px;
background: rgba(26, 34, 56, 0.1);
color: var(--nav-text);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
margin: 0 auto 1rem;
}
.success-block h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.success-block p {
font-size: 0.9rem;
color: var(--nav-text-muted);
margin: 0 0 0.5rem;
line-height: 1.5;
}
.success-detail {
font-size: 0.85rem !important;
}
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
.contribuer-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Champ générique ─────────────────────────────────────────────────────────── */
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label,
.field-group legend {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
display: block;
}
.field-group fieldset {
border: none;
padding: 0;
margin: 0;
}
.required {
color: #c0392b;
}
.label-hint {
font-weight: 400;
color: var(--nav-text-muted);
font-size: 0.8rem;
margin-left: 0.25rem;
}
.field-group input[type="text"],
.field-group input[type="url"],
.field-group input[type="email"],
.field-group select,
.field-group textarea {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
color: var(--nav-text);
background: var(--nav-surface);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group select {
cursor: pointer;
appearance: auto;
}
.field-group input:focus,
.field-group select:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid);
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
}
.field-group textarea {
resize: vertical;
min-height: 100px;
}
/* Erreur champ */
.field-error input,
.field-error select,
.field-error textarea {
border-color: #c0392b !important;
}
.error-msg {
font-size: 0.8rem;
color: #c0392b;
}
.field-meta {
display: flex;
justify-content: flex-end;
}
.char-count {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.char-warn {
color: #e67e22;
}
/* ── Radio (Type entité) ─────────────────────────────────────────────────────── */
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.375rem;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.radio-label input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.radio-label:hover {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.radio-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
/* ── Checkboxes (Critères) ───────────────────────────────────────────────────── */
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 0.375rem;
}
@media (max-width: 400px) {
.checkbox-grid {
grid-template-columns: 1fr;
}
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-label:hover:not(.disabled) {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.checkbox-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
.checkbox-label.disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Tags preview ────────────────────────────────────────────────────────────── */
.tags-preview {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.25rem;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.6rem;
background: var(--nav-bg-alt);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 100px;
font-size: 0.78rem;
color: var(--nav-text-muted);
}
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
.server-error {
padding: 0.875rem 1rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
/* ── Actions ──────────────────────────────────────────────────────────────────── */
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: rgba(26, 34, 56, 0.75);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.25rem;
background: transparent;
color: var(--nav-text-muted);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
.form-note {
font-size: 0.75rem;
color: var(--nav-text-muted);
text-align: center;
margin: 0;
}
/* ── Responsive ──────────────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.contribuer-page {
padding: 1rem 0.75rem 3rem;
}
.form-actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
justify-content: center;
}
}
</style>

56
pages/trouver-du-taf.vue Normal file
View File

@@ -0,0 +1,56 @@
<template>
<div class="trouver-du-taf-page">
<!-- Squelette V1 - sera étoffé par T4 (front Nuxt cascade TAFF) -->
<section class="intro">
<h1>Trouver du taf en archi</h1>
<p class="intro-text">
Annuaire critique des plateformes de mise en relation archi - particulier.
Évaluations sur 5 axes : rémunération, transparence, pratiques pro, écologie, qualité du matching.
</p>
<p class="intro-disclaimer">
Page en construction. Données à venir : T2 scoring 5 axes en cours après livraison T1.
</p>
</section>
<!-- Filtres : à brancher par T4 (FiltreSecteur, FiltreTag) -->
<!-- Liste plateformes : à brancher par T4 (FichePlateforme) -->
<!-- Chatbot d'aiguillage : à brancher par T6 (ChatbotTaff réutilise ChatbotSheet.vue) -->
</div>
</template>
<script setup lang="ts">
// Types disponibles : import type { PlateformeTaff, ScoringTaff, TagGlobal } from '~/types/plateforme-taff'
// Data attendue : public/data/plateformes-taff.json (livrée par T2 + T3 après T1)
useHead({
title: 'Trouver du taf en archi - AEP',
meta: [
{ name: 'description', content: "Annuaire critique des plateformes B2C archi - particulier. Évaluations éthiques sur 5 axes." }
]
})
</script>
<style scoped>
.trouver-du-taf-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
.intro h1 {
font-size: 2rem;
font-weight: 700;
color: var(--nav-text);
margin-bottom: 0.5rem;
}
.intro-text {
font-size: 1rem;
color: var(--nav-text);
line-height: 1.6;
margin-bottom: 1rem;
}
.intro-disclaimer {
font-size: 0.875rem;
color: var(--nav-text-muted);
font-style: italic;
}
</style>

View File

@@ -1 +0,0 @@
[]

View File

@@ -1,834 +0,0 @@
[
{
"id": 1,
"nom": "Rotor",
"pays": "BE",
"ville": "Bruxelles",
"type": "cooperative",
"url": "https://rotordb.org",
"lat": 50.8503,
"lng": 4.3517,
"description": "Pionnier européen du réemploi. Filiale RotorDC opérationnelle depuis 2016, showroom + entrepôt à Vilvorde. Publications de référence sur l'économie circulaire du bâtiment, expositions internationales.",
"criteres": [1, 2, 5, 6, 8],
"score": 5,
"tags": ["réemploi", "recherche", "publications", "coopérative"],
"source": "seed Jules + scrape WBDM",
"passe": 1
},
{
"id": 2,
"nom": "Assemble Studio",
"pays": "UK",
"ville": "Londres",
"type": "collectif",
"url": "https://assemblestudio.co.uk",
"lat": 51.5074,
"lng": -0.1278,
"description": "Turner Prize 2015. Travaille à la frontière architecture/art/design. Granby Workshop, Yardhouse, Sugarhouse Studios. Pratique non-hiérarchique, autoconstruction, fabrication interne.",
"criteres": [1, 3, 4, 6, 8],
"score": 5,
"tags": ["autoconstruction", "art", "social", "collectif horizontal"],
"source": "seed Jules",
"passe": 1
},
{
"id": 3,
"nom": "Forensic Architecture",
"pays": "UK",
"ville": "Londres",
"type": "recherche",
"url": "https://forensic-architecture.org",
"lat": 51.4742,
"lng": -0.0176,
"description": "Investigation architecturale autour de violations des droits humains et environnementaux. Contre-expertise vidéo/spatiale open-source. Balise politique du courant régénératif, transmission ouverte.",
"criteres": [5, 8],
"score": 2,
"tags": ["enquête", "politique", "contre-expertise", "open-source"],
"source": "seed Jules",
"passe": 1
},
{
"id": 4,
"nom": "Encore Heureux",
"pays": "FR",
"ville": "Paris",
"type": "agence",
"url": "https://encoreheureux.org",
"lat": 48.8566,
"lng": 2.3522,
"description": "Pavillon France Biennale Venise 2018 'Lieux Infinis'. Projets emblématiques sur le réemploi (Pavillon Circulaire, COP21). Manifeste, livres, posture publique forte.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["réemploi", "manifeste", "Biennale", "publications"],
"source": "seed Jules",
"passe": 1
},
{
"id": 5,
"nom": "Construire (Patrick Bouchain)",
"pays": "FR",
"ville": "Paris",
"type": "agence",
"url": "https://construire-architectes.com",
"lat": 48.8566,
"lng": 2.3522,
"description": "Maître d'œuvre du 'permis de faire'. Lieu Unique, Le Channel, friches culturelles. MOA habitante, pédagogie de chantier, transmission. Figure tutélaire du courant.",
"criteres": [3, 4, 5, 8],
"score": 4,
"tags": ["permis de faire", "MOA habitante", "friches", "transmission"],
"source": "seed Jules",
"passe": 1
},
{
"id": 6,
"nom": "Boidot+Robin",
"pays": "FR",
"ville": "Sézanne",
"type": "agence",
"url": "https://julienboidot.fr",
"lat": 48.7253,
"lng": 3.7211,
"description": "Architecture rurale champenoise, terre crue, bois local, sobriété matérielle radicale. Maisons individuelles et équipements publics. Engagement territorial fort, déshérence rurale.",
"criteres": [1, 2, 3, 8],
"score": 4,
"tags": ["terre crue", "rural", "low-tech", "filières courtes"],
"source": "seed Jules",
"passe": 1
},
{
"id": 7,
"nom": "Tepop",
"pays": "FR",
"ville": "Bordeaux",
"type": "cooperative",
"url": "https://tepop.fr",
"lat": 44.8378,
"lng": -0.5792,
"description": "Coopérative d'architecture et urbanisme. Démarches participatives, projets péri-urbains, modèle SCOP. Travaille sur la commande publique et habitat groupé.",
"criteres": [3, 4, 6, 8],
"score": 4,
"tags": ["SCOP", "participatif", "périurbain", "coopérative"],
"source": "seed Jules",
"passe": 1
},
{
"id": 8,
"nom": "Architecture & Précarités",
"pays": "FR",
"ville": "Paris",
"type": "reseau",
"url": "https://architecture-precarites.fr",
"lat": 48.8566,
"lng": 2.3522,
"description": "Réseau d'architectes engagés sur les questions de précarité, mal-logement, hospitalité. Recherche-action, publications, plaidoyer. Plateforme lancée 2022 par ENSA Paris-Belleville.",
"criteres": [4, 5, 8],
"score": 3,
"tags": ["précarités", "recherche-action", "hospitalité"],
"source": "seed Jules",
"passe": 1
},
{
"id": 9,
"nom": "Agence ATM",
"pays": "FR",
"ville": "Bagnolet",
"type": "agence",
"url": "https://agence-atm.com",
"lat": 48.8694,
"lng": 2.4200,
"description": "Architecture en terre, paille, bois. Chantiers-école, transmission. Projets en Île-de-France et province. Pratique low-tech assumée.",
"criteres": [1, 2, 3, 4],
"score": 4,
"tags": ["terre", "paille", "chantier-école", "low-tech"],
"source": "seed Jules",
"passe": 1
},
{
"id": 10,
"nom": "Frugalité Heureuse & Créative",
"pays": "FR",
"ville": "Paris",
"type": "mouvement",
"url": "https://frugalite.org",
"lat": 48.8566,
"lng": 2.3522,
"description": "Mouvement initié par Madec, Lefèvre, Rollet (2018). Manifeste signé par des milliers d'architectes. Expositions, publications, plaidoyer. Référence cardinale du courant frugal en France.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["manifeste", "sobriété", "low-tech", "mouvement"],
"source": "seed Jules",
"passe": 1
},
{
"id": 11,
"nom": "Atelier Belenfant Daubas",
"pays": "FR",
"ville": "Vertou",
"type": "agence",
"url": "https://atelierbelenfantdaubas.org",
"lat": 47.1667,
"lng": -1.4667,
"description": "Agence ligérienne. Bois local, paille, éco-construction sur équipements publics (écoles, salles). Pratique discrète mais référence du courant biosourcé Ouest.",
"criteres": [1, 2, 3],
"score": 3,
"tags": ["bois", "paille", "écoles", "Ouest"],
"source": "WebSearch BE",
"passe": 1
},
{
"id": 12,
"nom": "Réempro / Opalis",
"pays": "BE",
"ville": "Bruxelles",
"type": "plateforme",
"url": "https://opalis.eu",
"lat": 50.8503,
"lng": 4.3517,
"description": "Plateforme de référence pour le réemploi (revendeurs, fiches matériaux). Issue de la mouvance Rotor. Service complémentaire d'agence et outil documentaire ouvert.",
"criteres": [1, 2, 8],
"score": 3,
"tags": ["réemploi", "base de données", "open"],
"source": "WebSearch",
"passe": 1
},
{
"id": 13,
"nom": "BC Architects & Studies",
"pays": "BE",
"ville": "Bruxelles",
"type": "agence",
"url": "https://bc-as.org",
"lat": 50.8503,
"lng": 4.3517,
"description": "Architecture en terre crue, BTC, transmission via BC Studies (formation). Projets en Belgique, France, Maroc, Burundi. Une des références européennes terre.",
"criteres": [1, 2, 3, 8],
"score": 4,
"tags": ["terre crue", "BTC", "formation", "international"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 14,
"nom": "Lacol Arquitectura Cooperativa",
"pays": "ES",
"ville": "Barcelone",
"type": "cooperative",
"url": "https://lacol.coop",
"lat": 41.3851,
"lng": 2.1734,
"description": "Coopérative fondée 2009 (Sants). La Borda : plus grand bâtiment résidentiel bois d'Espagne à sa livraison. Modèle horizontal, transformation sociale via architecture, habitat coopératif.",
"criteres": [3, 4, 6, 8],
"score": 4,
"tags": ["SCOP", "La Borda", "habitat coopératif", "Barcelone"],
"source": "WebSearch",
"passe": 1
},
{
"id": 15,
"nom": "Recetas Urbanas",
"pays": "ES",
"ville": "Séville",
"type": "collectif",
"url": "https://recetasurbanas.net",
"lat": 37.3891,
"lng": -5.9845,
"description": "'Recettes urbaines' : méthodologies open-source pour autoconstruire dans les interstices légaux. Hack urbain, droit comme matériau. Cirugeda figure cardinale architecture sociale espagnole.",
"criteres": [4, 5, 6, 8],
"score": 4,
"tags": ["autoconstruction", "droit", "open-source", "hack"],
"source": "WebSearch",
"passe": 1
},
{
"id": 16,
"nom": "Die Baupiloten",
"pays": "DE",
"ville": "Berlin",
"type": "agence",
"url": "https://baupiloten.com",
"lat": 52.5200,
"lng": 13.4050,
"description": "Agence pédagogique (Susanne Hofmann + TU Berlin). Étudiants impliqués dans projets réels (écoles, logement). Méthodologie participative avec usagers, notamment enfants.",
"criteres": [4, 8],
"score": 2,
"tags": ["participation", "écoles", "pédagogie", "université"],
"source": "WebSearch",
"passe": 1
},
{
"id": 17,
"nom": "Baubotanik",
"pays": "DE",
"ville": "Munich",
"type": "recherche",
"url": "https://baubotanik.org",
"lat": 48.1351,
"lng": 11.5820,
"description": "Architecture vivante : structures construites par interaction technique + croissance végétale. Recherche fondamentale + démonstrateurs. Champ unique en Europe sur l'intégration vivant/structure.",
"criteres": [1, 7, 8],
"score": 3,
"tags": ["architecture vivante", "végétal", "recherche"],
"source": "WebSearch",
"passe": 1
},
{
"id": 18,
"nom": "Superuse Studios",
"pays": "NL",
"ville": "Rotterdam",
"type": "agence",
"url": "https://superuse-studios.com",
"lat": 51.9225,
"lng": 4.4792,
"description": "Pionnier néerlandais du réemploi industriel. Harvest Map (cartographie ressources). Projets emblématiques (Villa Welpeloo). Antériorité sur le sujet en Europe.",
"criteres": [1, 2, 5, 8],
"score": 4,
"tags": ["réemploi", "harvest map", "industriel", "NL"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 19,
"nom": "Atelier 56S",
"pays": "FR",
"ville": "Vannes",
"type": "agence",
"url": "https://56s.fr",
"lat": 47.6559,
"lng": -2.7603,
"description": "Agence morbihannaise, bois et paille, équipements publics ruraux. Représentante du courant biosourcé Bretagne. Pratique de mise en œuvre soignée, ancrage local.",
"criteres": [1, 2, 3],
"score": 3,
"tags": ["bois", "paille", "Bretagne", "rural"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 20,
"nom": "Quatorze",
"pays": "FR",
"ville": "Paris",
"type": "asso",
"url": "https://quatorze.cc",
"lat": 48.8566,
"lng": 2.3522,
"description": "Association d'architecture. Projets autour des migrations, du logement d'urgence, des fab labs (Fab City). Posture militante, modèle associatif assumé.",
"criteres": [3, 4, 5, 6],
"score": 4,
"tags": ["asso", "migrations", "fab city", "militant"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 21,
"nom": "Yes We Camp",
"pays": "FR",
"ville": "Marseille",
"type": "collectif",
"url": "https://yeswecamp.org",
"lat": 43.2965,
"lng": 5.3698,
"description": "Tiers-lieux et occupation transitoire. Les Grands Voisins, Coco Velten. Modèle hybride architecture/programmation/animation. Ancrage friches.",
"criteres": [3, 4, 5, 6],
"score": 4,
"tags": ["tiers-lieux", "friches", "transitoire", "programmation"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 22,
"nom": "Bellastock",
"pays": "FR",
"ville": "Île-Saint-Denis",
"type": "cooperative",
"url": "https://bellastock.com",
"lat": 48.9408,
"lng": 2.3472,
"description": "Coopérative spécialisée réemploi. Festival Bellastock annuel (chantier expérimental étudiants). Recherche + maîtrise d'œuvre. Une des entrées historiques du réemploi en France.",
"criteres": [1, 2, 3, 8],
"score": 4,
"tags": ["réemploi", "festival", "étudiants", "coopérative"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 23,
"nom": "Mario Cucinella Architects (TECLA)",
"pays": "IT",
"ville": "Bologne",
"type": "agence",
"url": "https://mcarchitects.it",
"lat": 44.4949,
"lng": 11.3426,
"description": "MCA. Projet TECLA : maison imprimée 3D en terre locale (avec WASP). Posture bioclimatique forte, démonstrateurs. Référence italienne sur l'expérimentation matériaux+vivant.",
"criteres": [1, 3, 7, 8],
"score": 4,
"tags": ["terre", "impression 3D", "bioclimatique", "IT"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 24,
"nom": "Ateliermob",
"pays": "PT",
"ville": "Lisbonne",
"type": "collectif",
"url": "https://ateliermob.com",
"lat": 38.7169,
"lng": -9.1399,
"description": "Collectif engagé 'Trabalhar com os 99%'. Autoconstruction, projets sociaux, Biennale Venise 2012. Architecture participative pour les quartiers informels et communautés défavorisées.",
"criteres": [3, 4, 5, 6, 8],
"score": 5,
"tags": ["autoconstruction", "social", "Biennale Venise 2012"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 25,
"nom": "Artéria",
"pays": "PT",
"ville": "Lisbonne",
"type": "agence",
"url": "https://arteria.pt",
"lat": 38.7169,
"lng": -9.1399,
"description": "Atelier de réhabilitation urbaine. Edifício Manifesto, Mouraria. Réhabilitation sensible du patrimoine populaire lisbonnais, réemploi de matériaux, pratique frugale.",
"criteres": [1, 3, 4, 5],
"score": 4,
"tags": ["réhabilitation", "Edifício Manifesto", "Mouraria"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 26,
"nom": "oitoo",
"pays": "PT",
"ville": "Porto",
"type": "agence",
"url": "https://oitoo.pt",
"lat": 41.1579,
"lng": -8.6291,
"description": "Réemploi, réactivation multi-sites. Pratique entre Porto et Lisbonne. Architecture de la transformation et du réemploi comme posture systématique.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["réemploi", "réactivation", "multi-sites"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 27,
"nom": "Tegnestuen LOKAL",
"pays": "DK",
"ville": "Copenhague",
"type": "agence",
"url": "https://tegnestuenlokal.dk",
"lat": 55.6761,
"lng": 12.5683,
"description": "Adaptive reuse, matériaux biogéniques, Manifest planétär. Posture frugale radicale, bois local, engagement écologique de fond dans toute la pratique.",
"criteres": [1, 2, 3, 7, 8],
"score": 5,
"tags": ["adaptive reuse", "biogenic", "Manifest planetær"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 28,
"nom": "Vandkunsten Architects",
"pays": "DK",
"ville": "Copenhague",
"type": "agence",
"url": "https://vandkunsten.com",
"lat": 55.6761,
"lng": 12.5683,
"description": "Agence danoise historique, beauté + sobriété, posture engagée sur la longévité du bâti. Publications, transmission de savoir-faire architectural durable.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["beauté+sobriété", "posture", "longévité"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 29,
"nom": "White Arkitekter",
"pays": "SE",
"ville": "Stockholm",
"type": "agence",
"url": "https://whitearkitekter.com",
"lat": 59.3293,
"lng": 18.0686,
"description": "Grande agence suédoise engagée sur les modes de vie durables. Matériaux biosourcés à grande échelle, publications, transmission du savoir-faire nordique.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["sustainable ways of life", "scale"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 30,
"nom": "ZRS Architekten Ingenieure",
"pays": "DE",
"ville": "Berlin",
"type": "agence",
"url": "https://zrs.berlin",
"lat": 52.5200,
"lng": 13.4050,
"description": "Terre, bambou, construction biosourcée en Allemagne et en Afrique subsaharienne (NBL). Recherche appliquée couplée à la pratique. Référence germanophone sur les matériaux locaux et vernaculaires.",
"criteres": [1, 2, 3, 5, 8],
"score": 5,
"tags": ["terre", "bambou", "NBL", "recherche"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 31,
"nom": "Natural Building Lab (TU Berlin)",
"pays": "DE",
"ville": "Berlin",
"type": "recherche",
"url": "https://nbl.berlin",
"lat": 52.5200,
"lng": 13.4050,
"description": "Laboratoire universitaire low-tech à la TU Berlin. Transmission, expérimentation constructive avec matériaux naturels, lien recherche-pratique exemplaire.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["low-tech", "université", "transmission"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 32,
"nom": "DGJ Architektur",
"pays": "DE",
"ville": "Frankfurt",
"type": "agence",
"url": "https://dgj.eu",
"lat": 50.1109,
"lng": 8.6821,
"description": "Agence engagée sur le durable et le biosourcé en Allemagne. Pratique soignée, matériaux biosourcés intégrés aux projets résidentiels et publics.",
"criteres": [1, 3, 8],
"score": 3,
"tags": ["sustainable", "biosourcés"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 33,
"nom": "Baubüro in situ + Zirkular",
"pays": "CH",
"ville": "Bâle",
"type": "agence",
"url": "https://insitu.ch",
"lat": 47.5596,
"lng": 7.5886,
"description": "Réemploi structurel de référence en Suisse. K118, Holcim Gold Award. Pratique circulaire systématique, publications, posture publique affirmée.",
"criteres": [1, 2, 5, 6, 8],
"score": 5,
"tags": ["K118", "réemploi structurel", "Holcim Gold"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 34,
"nom": "einszueins architektur",
"pays": "AT",
"ville": "Vienne",
"type": "agence",
"url": "https://einszueins.at",
"lat": 48.2082,
"lng": 16.3738,
"description": "Wohnprojekt Wien, baugruppe, World Habitat Award. Habitat coopératif et participatif à Vienne. Modèle alternatif de co-construction avec futurs habitants.",
"criteres": [3, 4, 5, 6, 8],
"score": 5,
"tags": ["Wohnprojekt Wien", "baugruppe", "World Habitat Award"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 35,
"nom": "BudCud",
"pays": "PL",
"ville": "Cracovie",
"type": "agence",
"url": "https://budcud.org",
"lat": 50.0647,
"lng": 19.9450,
"description": "Réemploi, mobilier urbain, pratique engagée à Cracovie. Projets d'espace public avec matériaux récupérés, participation des usagers.",
"criteres": [1, 3, 4, 8],
"score": 4,
"tags": ["réemploi", "mobilier", "urbain"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 36,
"nom": "Centrala",
"pays": "PL",
"ville": "Varsovie",
"type": "agence",
"url": "https://centrala.net.pl",
"lat": 52.2297,
"lng": 21.0122,
"description": "Recherche spatiale, posture critique. Agence-recherche varsovienne engagée sur les transformations urbaines post-industrielles.",
"criteres": [3, 5, 8],
"score": 3,
"tags": ["recherche spatiale", "posture"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 37,
"nom": "RiceHouse",
"pays": "IT",
"ville": "Biella",
"type": "agence",
"url": "https://ricehouse.it",
"lat": 45.5629,
"lng": 8.0565,
"description": "Paille de riz comme matériau de construction. Filière biosourcée ultra-locale (Plaine du Pô). Recherche + démonstrations, lien producteurs-constructeurs.",
"criteres": [1, 2, 3, 7, 8],
"score": 5,
"tags": ["paille de riz", "biosourcé", "filière courte"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 38,
"nom": "TAMassociati",
"pays": "IT",
"ville": "Venise",
"type": "agence",
"url": "https://tamassociati.org",
"lat": 45.4408,
"lng": 12.3155,
"description": "'Taking care' comme posture. Architecture humanitaire et sociale en Italie et à l'international. Projets de santé et d'éducation dans des contextes fragiles.",
"criteres": [3, 4, 5, 8],
"score": 4,
"tags": ["taking care", "humanitaire", "social"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 39,
"nom": "Diverserighestudio",
"pays": "IT",
"ville": "Bologne",
"type": "agence",
"url": "https://diverserighestudio.it",
"lat": 44.4949,
"lng": 11.3426,
"description": "Cohousing Porto15, recherche, participatif. Studio bolonais engagé sur l'habitat participatif et la recherche architecturale, modèle coopératif.",
"criteres": [3, 4, 5, 6, 8],
"score": 5,
"tags": ["cohousing Porto15", "recherche", "participatif"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 40,
"nom": "Material Cultures",
"pays": "UK",
"ville": "Londres",
"type": "asso",
"url": "https://materialcultures.org",
"lat": 51.5074,
"lng": -0.1278,
"description": "Biosourcés, chanvre (hempcrete), Harvest House. Association de recherche sur les cultures matérielles biosourcées et leur transformation en techniques constructives.",
"criteres": [1, 2, 5, 8],
"score": 4,
"tags": ["biosourcés", "hempcrete", "Harvest House"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 41,
"nom": "6a architects",
"pays": "UK",
"ville": "Londres",
"type": "agence",
"url": "https://6a.co.uk",
"lat": 51.5074,
"lng": -0.1278,
"description": "Strates, retrofit, transmission. Agence londonienne engagée sur la transformation du bâti existant, lecture des strates temporelles et sobriété des interventions.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["strates", "retrofit", "transmission"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 42,
"nom": "We Made That",
"pays": "UK",
"ville": "Londres",
"type": "agence",
"url": "https://wemadethat.co.uk",
"lat": 51.5074,
"lng": -0.1278,
"description": "Public realm, co-conception, civic. Agence engagée sur les espaces publics et l'aménagement participatif en contexte urbain dense.",
"criteres": [4, 5, 6, 8],
"score": 4,
"tags": ["public realm", "co-conception", "civic"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 43,
"nom": "Public Practice",
"pays": "UK",
"ville": "Londres",
"type": "asso",
"url": "https://publicpractice.org.uk",
"lat": 51.5074,
"lng": -0.1278,
"description": "Architectes placés en mairies pour œuvrer dans l'intérêt public. Modèle innovant d'insertion professionnelle au service de la commande publique locale.",
"criteres": [4, 5, 6, 8],
"score": 4,
"tags": ["architectes en mairies", "intérêt public"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 44,
"nom": "AgwA",
"pays": "BE",
"ville": "Bruxelles",
"type": "agence",
"url": "https://agwa.be",
"lat": 50.8503,
"lng": 4.3517,
"description": "Transformation, EUmies 2026, Charleroi. Agence bruxelloise spécialisée en transformation du bâti existant, posture critique et réemploi intégré.",
"criteres": [1, 5, 8],
"score": 3,
"tags": ["transformation", "EUmies 2026", "Charleroi"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 45,
"nom": "Carton123 architecten",
"pays": "BE",
"ville": "Bruxelles",
"type": "agence",
"url": "https://carton123.be",
"lat": 50.8503,
"lng": 4.3517,
"description": "Réemploi, école, collaboration. Agence bruxelloise engagée sur les projets scolaires avec une forte dimension réemploi et collaboration avec les usagers.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["réemploi", "school", "collaboration"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 46,
"nom": "KEBATI",
"pays": "MQ",
"ville": "Fort-de-France",
"type": "asso",
"url": "https://www.kebati.com",
"lat": 14.6161,
"lng": -61.0588,
"description": "Association citoyenne martiniquaise dédiée au bâtiment durable en milieu tropical humide. Centre de ressources reconnu nationalement (22 centres ADEME). 236 visites de chantiers, groupes de travail biosourcés, newsletter 800 abonnés.",
"criteres": [1, 2, 5, 8],
"score": 4,
"tags": ["biosourcés", "tropical", "transmission", "Martinique", "centre de ressources"],
"source": "P1-RECAP scrape kebati.com",
"passe": 3
},
{
"id": 47,
"nom": "AQUAA",
"pays": "GF",
"ville": "Cayenne",
"type": "asso",
"url": "https://www.aquaa.fr",
"lat": 4.9333,
"lng": -52.3333,
"description": "Association pour la qualité de la construction en Amazonie et aux Antilles. Guides techniques d'architecture bioclimatique guyanaise, éco-matériaux locaux, urbanisme équatorial. Membre fondateur du RBD Intertropical.",
"criteres": [2, 3, 5, 8],
"score": 4,
"tags": ["bioclimatique", "amazonien", "éco-matériaux", "Guyane"],
"source": "P1-RECAP — scrape échoué (ECONNREFUSED) — à compléter manuellement",
"passe": 3
},
{
"id": 48,
"nom": "Karibati",
"pays": "FR",
"ville": "Paris",
"type": "reseau",
"url": "http://www.karibati.fr",
"lat": 48.8566,
"lng": 2.3522,
"description": "Expert national du bâtiment biosourcé et géosourcé avec 15 ans d'expérience. Développement et structuration de filières biosourcées, accompagnement fabricants, formations, outils méthodologiques (aKacia, Label Produit Biosourcé).",
"criteres": [1, 2, 5, 8],
"score": 4,
"tags": ["biosourcés", "géosourcés", "filières", "formation", "national"],
"source": "P1-RECAP scrape karibati.fr",
"passe": 3
},
{
"id": 49,
"nom": "Réseau Bâtiment Durable Intertropical (RBD)",
"pays": "GP",
"ville": "Pointe-à-Pitre",
"type": "reseau",
"url": "https://www.synergile.fr/departements/rbd/",
"lat": 16.2410,
"lng": -61.5337,
"description": "Réseau intertropical de 4 structures (AQUAA Guyane, KEBATI Martinique, Envirobat Réunion, RBD Guadeloupe). Réponse concertée aux enjeux du bâtiment durable tropical. Pôle d'innovation Synergîles, financement ADEME.",
"criteres": [1, 2, 5, 8],
"score": 4,
"tags": ["réseau", "intertropical", "DOM-TOM", "ADEME"],
"source": "P1-RECAP scrape synergile.fr/rbd",
"passe": 3
},
{
"id": 50,
"nom": "Caribois",
"pays": "GP",
"ville": "Baie-Mahault",
"type": "agence",
"url": "https://www.caribois.com",
"lat": 16.2410,
"lng": -61.5337,
"description": "Constructeur de maisons individuelles en bois, béton ou mixte depuis 20 ans en Guadeloupe. Fabrication 100% locale, matériaux biosourcés (bois sélectionné), architecture créole. Accompagnement complet du financement à la livraison.",
"criteres": [1, 2],
"score": 2,
"tags": ["bois", "créole", "local", "Guadeloupe"],
"source": "P1-RECAP scrape caribois.com — constructeur commercial (pas collectif praticien au sens strict)",
"passe": 3
},
{
"id": 51,
"nom": "Vegetal(e)",
"pays": "GP",
"ville": "Pointe-à-Pitre",
"type": "plateforme",
"url": "http://www.vegetal-e.com/fr/guadeloupe_384.html",
"lat": 16.2410,
"lng": -61.5337,
"description": "Portail d'information et base de données sur les éco-matériaux et solutions constructives biosourcés en Guadeloupe. Agrégateur de ressources régionales (chanvre, cellulose, bois, bambou, paille). Newsletter hebdomadaire sur l'innovation construction.",
"criteres": [1, 5, 8],
"score": 3,
"tags": ["biosourcés", "portail", "Guadeloupe", "éco-matériaux"],
"source": "P1-RECAP scrape vegetal-e.com — portail info, pas une agence praticien",
"passe": 3
},
{
"id": 52,
"nom": "Envirobat Réunion",
"pays": "RE",
"ville": "Saint-Denis",
"type": "reseau",
"url": "https://www.envirobat.re",
"lat": -20.8789,
"lng": 55.4481,
"description": "Centre de ressources bâtiment durable de La Réunion. Membre fondateur du RBD Intertropical. Équivalent insulaire de KEBATI pour l'océan Indien, transmission et formation des professionnels du bâtiment réunionnais.",
"criteres": [2, 5, 8],
"score": 3,
"tags": ["bâtiment durable", "Réunion", "centre de ressources", "intertropical"],
"source": "P1-RECAP — scrape échoué (ECONNREFUSED) — à compléter manuellement",
"passe": 3
}
]

View File

@@ -1,127 +0,0 @@
// scripts/vectorize-v2.js
// Usage : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
// Génère : server/data/embeddings-v2.json
//
// SETUP AVANT DEPLOY :
// cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
// Coût estimé : ~0.10 EUR pour 120 fiches
//
// Prérequis : Node >= 18 (fetch natif disponible)
const fs = require('fs')
const path = require('path')
const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY
if (!MISTRAL_API_KEY) {
console.error('Erreur : MISTRAL_API_KEY manquante')
console.error('Usage : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js')
process.exit(1)
}
const dataPath = path.join(process.cwd(), 'public', 'data', 'reseaux-bifurcation.json')
const outPath = path.join(process.cwd(), 'server', 'data', 'embeddings-v2.json')
// Créer server/data/ si absent
const outDir = path.dirname(outPath)
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true })
const rawData = fs.readFileSync(dataPath, 'utf-8')
const data = JSON.parse(rawData)
const structures = data.structures
if (!Array.isArray(structures) || structures.length === 0) {
console.error('Erreur : aucune structure trouvée dans reseaux-bifurcation.json')
process.exit(1)
}
async function embedBatch(texts) {
const res = await fetch('https://api.mistral.ai/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${MISTRAL_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'mistral-embed',
input: texts
})
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Mistral API error ${res.status}: ${err}`)
}
const json = await res.json()
return json.data.map(d => d.embedding)
}
function buildText(s) {
const parts = [
s.nom,
s.description_courte ?? '',
(s.description_longue ?? '').slice(0, 800),
(s.hashtags ?? []).join(' '),
(s.sources ?? []).map(src => src.titre).join(' '),
(s.pensees ?? []).map(p => p.label).join(' ')
]
return parts.filter(Boolean).join('\n\n')
}
async function main() {
const embeddings = []
const BATCH_SIZE = 8 // Mistral embed : rate limit prudent
console.log(`Vectorisation de ${structures.length} structures (modele : mistral-embed)...`)
console.log(`Sortie : ${outPath}`)
console.log()
for (let i = 0; i < structures.length; i += BATCH_SIZE) {
const batch = structures.slice(i, i + BATCH_SIZE)
const texts = batch.map(buildText)
try {
const batchEmbeddings = await embedBatch(texts)
batch.forEach((s, j) => {
embeddings.push({
fiche_id: s.id,
nom: s.nom,
famille: s.famille_principale,
hashtags: s.hashtags ?? [],
embedding: batchEmbeddings[j],
text_preview: texts[j].slice(0, 300)
})
})
const batchNum = Math.floor(i / BATCH_SIZE) + 1
const totalBatches = Math.ceil(structures.length / BATCH_SIZE)
console.log(` Batch ${batchNum}/${totalBatches} OK (${batch.length} fiches)`)
// Pause rate limit entre batches
await new Promise(r => setTimeout(r, 200))
} catch (err) {
console.error(` Erreur batch ${i}-${i + BATCH_SIZE}:`, err.message)
process.exit(1)
}
}
const output = {
meta: {
total: embeddings.length,
model: 'mistral-embed',
date: new Date().toISOString(),
source: 'reseaux-bifurcation.json'
},
embeddings
}
fs.writeFileSync(outPath, JSON.stringify(output, null, 2), 'utf-8')
const sizeKb = Math.round(fs.statSync(outPath).size / 1024)
console.log()
console.log(`Done : ${embeddings.length} embeddings -> ${outPath}`)
console.log(`Taille : ${sizeKb} KB`)
console.log()
console.log('Prochaine etape : deployer le fichier sur le VPS avec les autres assets.')
}
main().catch(err => {
console.error('Erreur fatale :', err)
process.exit(1)
})

View File

@@ -1,304 +0,0 @@
/**
* POST /api/chatbot-pratiques
*
* Chatbot semantique sur la base des pratiques regeneratives (JSON statique).
* Adapte du endpoint /api/chatbot (ecosysteme AEP NocoDB).
*
* Flow :
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
* 2. Circuit breaker : budget 20 EUR/mois partage avec /api/chatbot
* 3. Lit public/data/pratiques-regeneratives.json (52 fiches V1)
* 4. Score keyword puis top 20 fiches en contexte
* 5. Appel Mistral Small avec prompt systeme adapte aux pratiques
* 6. Parse JSON -> { reponse_texte, fiches_recommandees }
* 7. Log stats_usage
*
* Reponse 200 : { reponse_texte, fiches_recommandees: [{ id, nom, explication }] }
* Reponse 429 : rate limit depasse
* Reponse 503 : budget IA epuise
*/
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
import { CRITERES, TYPES_ENTITE_LABELS, PAYS_LABELS } from '~/types/pratique'
import type { Pratique } from '~/types/pratique'
interface FicheReco {
id: number
nom: string
explication: string
}
interface MistralResponse {
reponse_texte: string
fiches_recommandees: FicheReco[]
}
// Prompt systeme dedie aux pratiques regeneratives.
// Difference avec /api/chatbot : on parle de pratiques, criteres rege (8 axes),
// types d'entites (agence, cooperative, collectif...), perimetre Europe + DOM-TOM.
const SYSTEM_PROMPT = `Tu es un assistant engage au service de la transition ecologique des pratiques architecturales. Tu accedes a la base AEP - Pratiques regeneratives, qui referencee les acteurs concrets de l'architecture regenerative en Europe et dans les DOM-TOM (agences, cooperatives, collectifs, reseaux, associations, plateformes, recherche).
CRITERES DE REGENERATION (8 axes utilises pour decrire chaque pratique) :
1. Materiaux (biosources, geosources, reemploi)
2. Filieres (locales, courtes, paysannes)
3. Posture (ethique, engagement politique, refus)
4. Process (collaboratif, participatif, lent)
5. Politique (lobbying, plaidoyer, contre-expertise)
6. Modele economique (cooperatif, low-tech, soutenable)
7. Vivant (biodiversite, sols, eau)
8. Transmission (formation, partage, pedagogie)
REGLES ABSOLUES :
1. Tu ne peux recommander QUE des pratiques presentes dans le contexte ci-dessous.
2. Ne jamais inventer une pratique absente du contexte.
3. Cite chaque pratique recommandee par son nom exact et son identifiant id.
4. Si le contexte ne contient aucune pratique pertinente, dis-le honnetement.
5. Reponses concises (200 mots max). Si l'usager demande explicitement plus de detail, tu peux developper.
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou apres.
7. Si la question est hors du champ architecture / ecologie / regeneration / territoire, recadre poliment.
FORMAT DE SORTIE :
{
"reponse_texte": "Ta reponse en prose (max 200 mots)",
"fiches_recommandees": [
{ "id": 12, "explication": "Pourquoi cette pratique repond a la question (1-2 phrases max)" }
]
}
CONTEXTE - Pratiques regeneratives disponibles :
{{FICHES_JSON}}`
function scorePratique(p: Pratique, keywords: string[]): number {
if (keywords.length === 0) return 1
const critereLabels = (p.criteres ?? [])
.map((cId) => CRITERES.find((c) => c.id === cId)?.label ?? '')
.join(' ')
const haystack = [
p.nom,
p.description,
p.ville,
p.type ? (TYPES_ENTITE_LABELS[p.type] ?? p.type) : '',
p.pays ? (PAYS_LABELS[p.pays] ?? p.pays) : '',
critereLabels,
(p.tags ?? []).join(' '),
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return keywords.reduce((score, kw) => score + (haystack.includes(kw) ? 1 : 0), 0)
}
function extractKeywords(question: string): string[] {
return question
.toLowerCase()
.replace(/[^\w\sàâäéèêëîïôùûüç-]/g, ' ')
.split(/\s+/)
.filter((w) => w.length >= 3)
.slice(0, 10)
}
function loadPratiques(): Pratique[] {
try {
const jsonPath = resolve(process.cwd(), 'public/data/pratiques-regeneratives.json')
const raw = readFileSync(jsonPath, 'utf-8')
return JSON.parse(raw) as Pratique[]
} catch (e) {
console.error('[chatbot-pratiques] Erreur lecture JSON:', (e as Error).message)
return []
}
}
async function logUsage(params: {
nocodbUrl: string
nocodbToken: string
statsTableId: string
tokensIn: number
tokensOut: number
coutEur: number
}) {
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
try {
await $fetch(logUrl, {
method: 'POST',
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'mistral-small-latest',
endpoint: 'chatbot-pratiques',
tokens_in: tokensIn,
tokens_out: tokensOut,
cout_eur: coutEur,
timestamp: new Date().toISOString(),
orga_id: null,
}),
})
} catch (e) {
console.warn('[chatbot-pratiques] Log stats_usage echoue (non bloquant):', (e as Error).message)
}
}
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
// 1. IP (proxy-aware)
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
// 2. Rate limit : 10 req/IP/jour (compteur dedie chatbot-pratiques)
const allowed = checkRateLimitJson(ip, 'chatbot-pratiques', 10)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Limite de 10 questions par jour atteinte.',
})
}
// 3. Lire le body
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
if (!question || question.length < 3) {
throw createError({
statusCode: 400,
statusMessage: 'Question trop courte.',
})
}
// 4. Circuit breaker budget partage
const statsTableId = process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc'
const budget = await checkBudget({
nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string,
statsTableId,
})
if (budget.blocked) {
throw createError({
statusCode: 503,
statusMessage: 'Budget IA mensuel epuise - reouverture le 1er du mois prochain.',
})
}
// 5. Charger pratiques + scoring
const allPratiques = loadPratiques()
if (allPratiques.length === 0) {
throw createError({
statusCode: 503,
statusMessage: 'Donnees pratiques indisponibles.',
})
}
const keywords = extractKeywords(question)
const scored = allPratiques
.map((p) => ({ pratique: p, score: scorePratique(p, keywords) }))
.sort((a, b) => b.score - a.score)
.slice(0, 20)
.map((x) => x.pratique)
const fichesContext = scored.map((p) => ({
id: p.id,
nom: p.nom,
type: p.type ? (TYPES_ENTITE_LABELS[p.type] ?? p.type) : '',
pays: p.pays ? (PAYS_LABELS[p.pays] ?? p.pays) : '',
ville: p.ville ?? '',
criteres: (p.criteres ?? [])
.map((cId) => CRITERES.find((c) => c.id === cId)?.label ?? '')
.filter(Boolean),
description: (p.description ?? '').slice(0, 250),
tags: (p.tags ?? []).slice(0, 5),
}))
const systemPrompt = SYSTEM_PROMPT.replace(
'{{FICHES_JSON}}',
JSON.stringify(fichesContext, null, 0),
)
// 6. Appel Mistral Small
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({
statusCode: 500,
statusMessage: 'Cle API Mistral manquante.',
})
}
let mistralRaw: string
let tokensIn = 0
let tokensOut = 0
try {
const mistralRes = await $fetch<{
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
}>('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 600,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question },
],
}),
})
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
tokensOut = mistralRes.usage?.completion_tokens ?? 0
} catch (e: any) {
console.error('[chatbot-pratiques] Erreur Mistral Small:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur appel IA - reessaie dans quelques instants.',
})
}
// 7. Parse JSON
let parsed: MistralResponse
try {
const raw = JSON.parse(mistralRaw)
parsed = {
reponse_texte: raw.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
fiches_recommandees: (raw.fiches_recommandees ?? []).map((f: any) => {
const p = scored.find((x) => x.id === f.id)
return {
id: f.id,
nom: p?.nom ?? f.nom ?? `Fiche #${f.id}`,
explication: f.explication ?? '',
}
}),
}
} catch {
parsed = {
reponse_texte: "Je n'ai pas pu analyser ta demande correctement.",
fiches_recommandees: [],
}
}
// 8. Log usage (non bloquant)
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
logUsage({
nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string,
statsTableId,
tokensIn,
tokensOut,
coutEur,
})
return parsed
})

View File

@@ -1,20 +0,0 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import type { Pratique } from '~/types/pratique'
/**
* GET /api/pratiques
* Lit public/data/pratiques-regeneratives.json
* Retourne { list: Pratique[], source: 'static' }
*/
export default defineEventHandler(async (_event) => {
try {
const jsonPath = resolve(process.cwd(), 'public/data/pratiques-regeneratives.json')
const raw = readFileSync(jsonPath, 'utf-8')
const list: Pratique[] = JSON.parse(raw)
return { list, source: 'static' }
} catch (err) {
console.error('[PRATIQUES API] Erreur lecture JSON:', err)
throw createError({ statusCode: 503, message: 'Données pratiques-regeneratives indisponibles' })
}
})

View File

@@ -1,117 +0,0 @@
// Modération : Jules consulte public/data/pratiques-pending.json,
// déplace les entrées validées dans public/data/pratiques-regeneratives.json,
// supprime de pending. À automatiser en V2 (UI admin).
import { z } from 'zod'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { resolve } from 'path'
// ── Schéma Zod (miroir du client) ─────────────────────────────────────────────
export const PratiqueSubmitSchema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z
.string()
.url('URL invalide (commencer par https://)')
.optional()
.or(z.literal(''))
.transform((v) => v || undefined),
description_user: z
.string()
.min(50, 'Minimum 50 caractères')
.max(500, 'Maximum 500 caractères')
.trim(),
criteres: z
.array(z.number().int().min(1).max(8))
.min(3, 'Sélectionne au moins 3 critères')
.max(8, 'Maximum 8 critères'),
pays: z.string().length(2, 'Code pays invalide').or(z.literal('AUTRE')),
pays_autre: z.string().max(50).optional(),
ville: z.string().max(100).optional().transform((v) => v?.trim() || undefined),
type: z.enum([
'agence', 'cooperative', 'collectif', 'reseau', 'asso',
'recherche', 'mouvement', 'plateforme', 'inconnu',
], { errorMap: () => ({ message: 'Type d\'entité invalide' }) }),
tags: z.array(z.string().max(30)).max(6).optional(),
submitted_by_email: z
.string()
.email('Email invalide')
.optional()
.or(z.literal(''))
.transform((v) => v || undefined),
})
export type PratiqueSubmitInput = z.infer<typeof PratiqueSubmitSchema>
// ── Chemin du fichier pending ─────────────────────────────────────────────────
function getPendingPath(): string {
return resolve(process.cwd(), 'public/data/pratiques-pending.json')
}
function readPending(): PratiqueSubmitInput[] {
const path = getPendingPath()
try {
if (!existsSync(path)) return []
return JSON.parse(readFileSync(path, 'utf-8'))
} catch {
return []
}
}
function writePending(entries: unknown[]) {
writeFileSync(getPendingPath(), JSON.stringify(entries, null, 2), 'utf-8')
}
// ── Handler principal ─────────────────────────────────────────────────────────
export default defineEventHandler(async (event) => {
// 1. Récupérer l'IP (proxy-aware)
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
// 2. Rate limit JSON : 3 soumissions / IP / jour
const allowed = checkRateLimitJson(ip, 'submit-pratique', 3)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Trop de soumissions. Réessaie demain.',
})
}
// 3. Lire et valider le body
const body = await readBody(event)
const parsed = PratiqueSubmitSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation échouée',
data: parsed.error.flatten().fieldErrors,
})
}
const data = parsed.data
// 4. Construire l'entrée pending
const timestamp = Date.now()
const entry = {
...data,
id: timestamp,
submitted_at: new Date().toISOString(),
moderation_status: 'pending' as const,
}
// 5. Append à pratiques-pending.json
const pending = readPending()
pending.push(entry)
writePending(pending)
return {
ok: true,
trackingId: timestamp,
}
})

106
types/plateforme-taff.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Types V1 — Carte 3 AEP "Trouver du taf en archi"
* Source : public/data/plateformes-taff.json
* Spec figée : 0 INBOX/PROMPTS/cascade-megaboum/MP-TAFF-app-trouver-du-taf.md
*/
export type AxeScore = "✅" | "⚠️" | "❌";
export type TagGlobal = "recommande" | "sous-reserve" | "a-eviter";
export type Secteur =
| "renovation"
| "construction-neuve"
| "urbanisme"
| "architecture-interieure"
| "paysage"
| "mar-conseil"
| "transversal";
export type TypePlateforme =
| "b2c-mise-en-relation" // V1 cible (Travaux.com, Habitatpresto, etc.)
| "appel-offre-public" // V1 onglet bonus light
| "communaute-pro"; // backlog V2 (Welow, etc.)
export type CoutEntree = "gratuit" | "freemium" | "abonnement" | "lead-paye" | "commission";
export type ZoneGeo = "france-entiere" | "regional" | string;
export interface ScoringTaff {
// Pour b2c-mise-en-relation : tous les 5 axes sont remplis.
// Pour appel-offre-public : seuls transparence + matching sont remplis,
// les 3 autres sont null (scoring simplifié décision F du MP).
remuneration: AxeScore | null;
transparence: AxeScore;
pratiques: AxeScore | null;
ecologie: AxeScore | null;
matching: AxeScore;
tag_global: TagGlobal;
justification_tag: string; // 1-2 phrases pourquoi ce tag
}
export interface Commentaire {
id: string;
date: string;
auteur_pseudo: string;
contenu: string;
modere: boolean;
}
export interface PlateformeTaff {
id: string; // slug-kebab
nom: string;
url: string;
type: TypePlateforme;
description: string; // IA 250 mots (5 sections fixes ≤50 mots)
description_courte: string; // IA 30 mots (carte preview)
scoring: ScoringTaff;
secteurs_servis: Secteur[];
zone_geo: ZoneGeo; // si "regional", précise zones
cout_entree: CoutEntree;
date_creation_fiche: string; // ISO
date_derniere_maj: string; // ISO — pour pipeline trimestriel
source_donnees: string[]; // URLs scrapées
flag_validation_jules: boolean; // true si tag ❌ validé manuellement
commentaires?: Commentaire[];
}
export interface PlateformesTaffData {
meta: {
version: string;
date_generation: string; // ISO
total: number;
repartition: {
recommande: number;
sous_reserve: number;
a_eviter: number;
};
repartition_type: {
b2c: number;
appel_offre_public: number;
};
};
plateformes: PlateformeTaff[];
}
// ── Helpers ────────────────────────────────────────────────────────────────────
export const FAMILLES_SECTEUR: { id: Secteur; label: string; color: string }[] = [
{ id: "renovation", label: "Rénovation", color: "#5a7a4a" },
{ id: "construction-neuve", label: "Construction neuve", color: "#3d6a8c" },
{ id: "urbanisme", label: "Urbanisme", color: "#6b3fa0" },
{ id: "architecture-interieure", label: "Archi intérieure", color: "#a85d3e" },
{ id: "paysage", label: "Paysage", color: "#5a7a4a" },
{ id: "mar-conseil", label: "MAR / Conseil", color: "#c4a472" },
{ id: "transversal", label: "Transversal", color: "#888888" },
];
export const TAG_LABELS: Record<TagGlobal, { label: string; emoji: string; color: string }> = {
"recommande": { label: "Recommandé AEP", emoji: "✅", color: "#5a7a4a" },
"sous-reserve": { label: "Sous réserve", emoji: "⚠️", color: "#c4a472" },
"a-eviter": { label: "À éviter", emoji: "❌", color: "#a85d3e" },
};

View File

@@ -1,69 +0,0 @@
/**
* Interface canonique Pratique — AEP Pratiques Régénératives
* Source unique : types/pratique.ts
* Importée dans pages/pratiques-regeneratives.vue, pages/pratique/[id].vue
*/
export interface Pratique {
id: number
nom: string
pays: string // ISO-2 — Europe (FR/BE/...) ou DOM-TOM (GP/MQ/GF/RE/YT/PF/NC/...)
ville: string
type: 'agence' | 'cooperative' | 'collectif' | 'reseau' | 'asso' | 'recherche' | 'mouvement' | 'plateforme' | 'inconnu'
url: string
lat: number | null
lng: number | null
description: string
criteres: number[] // 1-8
score: number
tags: string[]
source: string
passe: 1 | 2 | 3
}
export const CRITERES = [
{ id: 1, label: 'Matériaux' },
{ id: 2, label: 'Filières' },
{ id: 3, label: 'Posture' },
{ id: 4, label: 'Process' },
{ id: 5, label: 'Politique' },
{ id: 6, label: 'Modèle éco' },
{ id: 7, label: 'Vivant' },
{ id: 8, label: 'Transmission' },
] as const
export const TYPES_ENTITE = [
'agence',
'cooperative',
'collectif',
'reseau',
'asso',
'recherche',
'mouvement',
'plateforme',
'inconnu',
] as const
export const TYPES_ENTITE_LABELS: Record<string, string> = {
agence: 'Agence',
cooperative: 'Coopérative',
collectif: 'Collectif',
reseau: 'Réseau',
asso: 'Association',
recherche: 'Recherche',
mouvement: 'Mouvement',
plateforme: 'Plateforme',
inconnu: 'Autre',
}
export const EUROPE_CODES = ['FR', 'BE', 'UK', 'DE', 'ES', 'NL', 'CH', 'IT', 'PT', 'SE', 'DK', 'FI', 'NO', 'PL', 'CZ', 'AT'] as const
export const OUTREMER_CODES = ['GP', 'MQ', 'GF', 'RE', 'YT', 'PF', 'NC', 'BL', 'MF', 'PM', 'WF'] as const
export const PAYS_LABELS: Record<string, string> = {
FR: 'France', BE: 'Belgique', UK: 'Royaume-Uni', DE: 'Allemagne',
ES: 'Espagne', NL: 'Pays-Bas', CH: 'Suisse', IT: 'Italie',
PT: 'Portugal', SE: 'Suède', DK: 'Danemark', FI: 'Finlande',
NO: 'Norvège', PL: 'Pologne', CZ: 'Tchéquie', AT: 'Autriche',
GP: 'Guadeloupe', MQ: 'Martinique', GF: 'Guyane', RE: 'La Réunion',
YT: 'Mayotte', PF: 'Polynésie française', NC: 'Nouvelle-Calédonie',
BL: 'Saint-Barthélemy', MF: 'Saint-Martin', PM: 'Saint-Pierre-et-Miquelon', WF: 'Wallis-et-Futuna',
}