15 Commits

Author SHA1 Message Date
Jules Neny
5878c56888 fix(chatbot): switch endpoint v1 -> v2 + script vectorize CJS + payload Mistral input
- ChatbotPlaceholder.vue : fetch /api/chatbot -> /api/chatbot-v2 (utilise embeddings Mistral)
- scripts/vectorize-v2.js renomme en .cjs (package.json type=module incompat avec require)
- Fix payload Mistral : 'inputs' (deprecated) -> 'input' (API actuelle)
- Vectorisation testee : 120 embeddings -> server/data/embeddings-v2.json (3.4MB, gitignored)
- Cle Mistral en rotation : nouvelle dans .env local (pas commit) + a synchroniser sur VPS
2026-05-06 01:20:40 +02:00
Jules Neny
755d1ef9ae feat(v2-graphe): sidebar hashtags droite repliable + chatbot integre
- GraphView : barre hashtags du haut supprimee, sidebar droite 200px
  groupee par famille (heuristique majoritaire, egalite -> famille la
  plus petite), toggle >>/<< vers 40px replie
- pages/index.vue : ChatbotPlaceholder ajoute sous GraphView en flex-column
- watch sidebarOpen : reinit D3 apres transition CSS pour SVG resize
2026-05-05 17:58:38 +02:00
Jules Neny
682d5d337e fix(aep-v1.1): bugs E2E M1 M2 M3 L1 L2 L3
M1 - Chips a11y : converti les <span> chips mobile (criteres, types,
echelle, fonctions) en <button type=button> avec aria-pressed pour
support clavier et lecteurs d'ecran (sidebar desktop deja en boutons).

M2 - Effacer les filtres ne vide pas la search : resetFilters() reset
maintenant aussi mobileSearch dans pratiques-regeneratives.vue et
index.vue.

M3 - FAB Soutenir overlap chip Agence : repositionne le FAB Soutenir
en stack vertical avec le FAB Chatbot (right: 16px, bottom: 84px) au
lieu de left: 16px, bottom: 68px. Evite l'overlap avec les chips de
la bottom-sheet sur viewport intermediaire.

L1 - /fiche/[id] introuvable pour pratiques : ajoute un fallback dans
pages/fiche/[id].vue qui detecte si l'id correspond a une pratique
regenerative et redirige vers /pratique/[id] (navigateTo replace).

L2 - Label retour incorrect sur /proposer-pratique : harmonise en
'Retour aux pratiques regeneratives'.

L3 - Map ne fitBounds pas apres filtre : EuropeMap et NavMap appellent
maintenant fitBounds(bounds, padding 40px, maxZoom 10) quand la liste
filtree contient 1 a 15 markers. Saute le tout premier rendu pour
preserver la vue initiale.
2026-04-30 02:31:31 +02:00
Jules Neny
914759a815 feat(aep-v1.1): PA5 chatbot pratiques regeneratives
- Nouveau endpoint server/api/chatbot-pratiques.post.ts qui interroge
  le JSON statique pratiques-regeneratives.json (52 fiches V1) avec
  Mistral Small. Prompt systeme adapte aux 8 criteres rege et types
  d'entites. Rate limit 10/jour, circuit breaker partage.
- ChatbotPlaceholder + ChatbotSheet rendus generiques via props
  (endpoint, title, placeholder, ficheBasePath) + slot onboarding.
  La carte ecosysteme AEP continue d'utiliser /api/chatbot, la carte
  pratiques rege utilise /api/chatbot-pratiques.
- pratiques-regeneratives.vue : ChatbotPlaceholder integre sous la
  carte Europe desktop (replie par defaut), FAB mobile + ChatbotSheet
  bottom sheet, handler highlightOrgs pour surligner la fiche reco.
2026-04-30 02:29:16 +02:00
Jules Neny
a6fff9a950 feat(aep-v1.1): PA3 bouton Proposer contextuel + CTA sidebar ecosysteme
- app.vue : bouton + Proposer du header pointe vers /proposer-pratique
  si on est sur la carte pratiques regenerative (et sous-pages /pratique/),
  sinon vers /contribuer (ecosysteme AEP par defaut). Idem icone mobile.
- NavSidebar.vue : ajoute le CTA + Proposer une fiche en pied de sidebar
  (style aligne sur PratiqueSidebar.vue)
2026-04-30 02:26:24 +02:00
Jules Neny
358cb55d50 feat(aep-v1.1): PA1 fix DOM-TOM pattern desktop 2 onglets
- Remplace le bandeau DOM-TOM 140px (UX cassée, pin inaccessible) par
  un pattern 2 onglets en haut de carte (Metropole/Europe vs Outre-mer)
- Applique le pattern symetriquement sur pratiques-regeneratives.vue
  et index.vue
- Carte selectionnee occupe toute la hauteur dispo, accordeon DOM-TOM
  scrollable
2026-04-30 02:25:42 +02:00
Jules Neny
bbf6b0475d docs(p5b): recap deploy prod — smoke test 3/3, 52 fiches, notes deploy.sh BOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:46:49 +02:00
Jules Neny
bf40b40f96 docs(p5b): journal deploy + INDEX + prompt BrowserMCP E2E
- JOURNAL-V2.md : entree 2026-04-29, chantier P1->P5b, smoke test prod
- aep-communaute-build/INDEX.md : 10/11 cases cochees (manque E2E Jules)
- aep-communaute-build/PROMPT-BROWSERMCP-E2E.md : prompt E2E 5 scenarios desktop+mobile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:46:00 +02:00
Jules Neny
e80b226ba2 docs(p5a): recap build local + add pratiques-regeneratives.json data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:35:04 +02:00
Jules Neny
90808551f0 docs(p4): recap P4 form proposer-pratique
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:34:57 +02:00
Jules Neny
f25a7d3884 feat(pratiques): pending.json init + CTA sidebar proposer une pratique
pratiques-pending.json initialisé vide (file modération V1).
PratiqueSidebar : lien + Proposer une pratique en bas de sidebar,
style sidebar-cta-link réutilisant variables CSS existantes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:33:45 +02:00
Jules Neny
d10586c432 feat(pratiques): page /proposer-pratique — formulaire contribution Pratique
Formulaire complet : nom, URL, description (50-500c), critères régé
(checkboxes min 3/8), type entité (radio), pays (dropdown Europe + DOM-TOM
+ autre), ville, tags (virgule-séparé, chips preview), email optionnel.
Validation Zod client-side champ par champ + submit, gestion 422/429.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:33:15 +02:00
Jules Neny
83d4bd12fa feat(pratiques): endpoint POST /api/submit-pratique avec Zod + rate limit
Validation Zod miroir schéma client, 3 soumissions/IP/jour via
rateLimitJson, append à pratiques-pending.json, retourne trackingId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:32:45 +02:00
Jules Neny
5fabcdee8a feat(nav): ajout onglet Pratiques régé + hamburger + overflow 2026-04-28 21:48:04 +02:00
Jules Neny
a70c9e0b4f feat(pratiques): types, API statique, composants filtres + cartes Europe/outremer 2026-04-28 21:47:41 +02:00
51 changed files with 5397 additions and 3928 deletions

View File

@@ -11,6 +11,48 @@ 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
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen

View File

@@ -0,0 +1,45 @@
# 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

@@ -0,0 +1,76 @@
# 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

@@ -0,0 +1,59 @@
# 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

@@ -0,0 +1,61 @@
# 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

@@ -0,0 +1,172 @@
# 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`).

48
app.vue
View File

@@ -34,26 +34,20 @@
>
Écosystème Entraide Architecture
</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
to="/agences"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/agences' }"
>
Réseaux AEP
</NuxtLink>
<NuxtLink
to="/trouver-du-taf"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/trouver-du-taf' }"
>
Jobs
</NuxtLink>
<NuxtLink
to="/codev"
class="nav-tab"
:class="{ 'nav-tab--active': route.path.startsWith('/codev') }"
>
Codev
Agences Inspirantes
<span class="nav-tab-badge">en construction</span>
</NuxtLink>
<NuxtLink
to="/rag"
@@ -113,9 +107,9 @@
>
Signaler
</NuxtLink>
<!-- Proposer une ressource -->
<!-- Proposer une ressource (cible contextuelle selon la carte active) -->
<NuxtLink
to="/contribuer"
:to="proposeTarget"
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);"
>
@@ -142,9 +136,9 @@
</svg>
</button>
<!-- Mobile : contribuer icône -->
<!-- Mobile : contribuer icône (cible contextuelle) -->
<NuxtLink
to="/contribuer"
:to="proposeTarget"
class="sm:hidden p-2 rounded-lg"
style="background: var(--nav-accent); color: var(--nav-text);"
title="Contribuer une fiche"
@@ -178,9 +172,9 @@
@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="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Réseaux AEP</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="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
@@ -190,7 +184,7 @@
</header>
<!-- Contenu page (flex-1 pour remplir l'espace) -->
<div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
<div class="flex-1" :class="(route.path === '/' || route.path === '/pratiques-regeneratives') ? 'overflow-hidden' : 'overflow-y-auto'">
<NuxtPage />
</div>
@@ -259,6 +253,16 @@ function clearHeaderSearch() {
function goRandom() {
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>
<style>

View File

@@ -461,10 +461,12 @@ const jaugePct = computed(() => {
}
/* ── 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 {
position: fixed;
bottom: 68px; /* au-dessus du FAB chatbot à 24px du bas + 48px de hauteur */
left: 16px;
bottom: 84px; /* au-dessus du FAB chatbot a bottom-6 (24px) + 48px de hauteur + 12px gap */
right: 16px;
z-index: 1000;
width: 44px;
height: 44px;

View File

@@ -52,18 +52,9 @@
<div class="chatbot-body-inner" ref="messagesContainer">
<!-- Onboarding -->
<div v-if="messages.length === 0" class="onboarding-bubble">
<p>Ce chatbot fonctionne sur un serveur européen souverain
(Mistral FR, zéro rétention), conçu sobre en énergie.</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>
<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 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>
<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>
</div>
<!-- Messages -->
@@ -72,7 +63,7 @@ employeur, besoin conseil juridique droit du travail,
<div v-else class="assistant-bubble">
<p>{{ msg.content }}</p>
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
<p class="fiches-title">Fiches recommandées :</p>
<p class="fiches-title">Fiches recommandees :</p>
<a
v-for="fiche in msg.fiches"
:key="fiche.id"
@@ -83,6 +74,21 @@ employeur, besoin conseil juridique droit du travail,
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
</a>
</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>
</template>
@@ -132,10 +138,12 @@ interface ChatMessage {
role: 'user' | 'assistant'
content: string
fiches?: FicheReco[]
suggestedHashtags?: string[]
}
const emit = defineEmits<{
'highlightOrgs': [ids: (number | string)[]]
'applyHashtag': [tag: string]
}>()
const isExpanded = ref(false)
@@ -145,6 +153,37 @@ const loading = ref(false)
const errorMsg = ref('')
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() {
isExpanded.value = !isExpanded.value
}
@@ -165,15 +204,17 @@ async function sendMessage() {
const res = await $fetch<{
reponse_texte: string
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
}>('/api/chatbot', {
}>('/api/chatbot-v2', {
method: 'POST',
body: { question },
})
const suggestedHashtags = detectHashtagsFromQuery(question)
const assistantMsg: ChatMessage = {
role: 'assistant',
content: res.reponse_texte,
fiches: res.fiches_recommandees || [],
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
}
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"/>
</svg>
</div>
<span class="font-bold text-sm" style="color: var(--nav-text);">Chatbot</span>
<span class="font-bold text-sm" style="color: var(--nav-text);">{{ title }}</span>
</div>
</div>
@@ -69,18 +69,20 @@
<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) -->
<div v-if="messages.length === 0" class="onboarding-bubble">
<p>Ce chatbot fonctionne sur un serveur européen souverain
<slot name="onboarding">
<p>Ce chatbot fonctionne sur un serveur européen souverain
(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,
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
<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>
</slot>
</div>
<!-- Messages -->
@@ -100,7 +102,7 @@ employeur, besoin conseil juridique droit du travail,
<a
v-for="fiche in msg.fiches"
:key="fiche.id"
:href="`/fiche/${fiche.id}`"
:href="`${ficheBasePath}/${fiche.id}`"
class="fiche-card"
>
<span class="fiche-nom">{{ fiche.nom }}</span>
@@ -176,9 +178,16 @@ interface ChatMessage {
fiches?: FicheReco[]
}
const props = defineProps<{
const props = withDefaults(defineProps<{
modelValue: boolean
}>()
endpoint?: string
title?: string
ficheBasePath?: string
}>(), {
endpoint: '/api/chatbot',
title: 'Chatbot',
ficheBasePath: '/fiche',
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
@@ -225,7 +234,7 @@ async function sendMessage() {
const res = await $fetch<{
reponse_texte: string
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
}>('/api/chatbot', {
}>(props.endpoint, {
method: 'POST',
body: { question },
})

View File

@@ -0,0 +1,41 @@
<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>

253
components/EuropeMap.vue Normal file
View File

@@ -0,0 +1,253 @@
<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>

435
components/GraphView.vue Normal file
View File

@@ -0,0 +1,435 @@
<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,6 +128,8 @@ async function initMap() {
updateMarkers(L)
}
let initialFitDone = false
function updateMarkers(L?: any) {
if (!mapInstance || !clusterGroup) return
const leaflet = L || (window as any).L
@@ -168,6 +170,25 @@ function updateMarkers(L?: any) {
markers.set(org.Id, 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)
@@ -221,12 +242,7 @@ function updateTileTheme(dark: boolean) {
let themeObserver: MutationObserver | null = null
onMounted(() => {
// Double rAF : laisser le browser calculer la hauteur du conteneur avant Leaflet
requestAnimationFrame(() => {
requestAnimationFrame(() => {
initMap()
})
})
initMap()
// Observer les changements de classe dark sur <html>
themeObserver = new MutationObserver(() => {

View File

@@ -125,8 +125,8 @@
<span
v-for="fn in orgFonctions(org)"
:key="fn"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
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-0.5 text-xs" style="color: var(--nav-text-muted);">
@@ -136,6 +136,18 @@
</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>
</template>
@@ -254,4 +266,24 @@ function orgFonctions(org: Org): string[] {
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

@@ -0,0 +1,276 @@
<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>

60
components/PaysFilter.vue Normal file
View File

@@ -0,0 +1,60 @@
<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

@@ -0,0 +1,274 @@
<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

@@ -0,0 +1,41 @@
<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

@@ -1,450 +0,0 @@
<template>
<div ref="container" class="codev-graph-wrap">
<!-- Placeholder si aucune fiche -->
<div v-if="fiches.length === 0" class="empty-state">
<p class="empty-msg">Encore personne. Sois la premiere fiche !</p>
<NuxtLink to="/codev/fiche" class="empty-link">Creer ma fiche &rarr;</NuxtLink>
</div>
<!-- SVG D3 -->
<svg v-else ref="svgEl" class="codev-svg">
<defs>
<marker
id="arrow-solution"
viewBox="0 0 10 10"
refX="18"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#22c55e" />
</marker>
</defs>
</svg>
</div>
</template>
<script setup lang="ts">
import * as d3 from 'd3'
import type { CodevFiche, CodevMatch } from '~/types/codev'
// ── Props / Emits ──────────────────────────────────────────────────────────
const props = withDefaults(defineProps<{
fiches: CodevFiche[]
matches?: CodevMatch[]
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
showLabels?: boolean
}>(), {
matches: () => [],
mode: 'none',
showLabels: false,
})
const emit = defineEmits<{
'select-fiche': [id: number]
}>()
// ── Refs ───────────────────────────────────────────────────────────────────
const container = ref<HTMLDivElement | null>(null)
const svgEl = ref<SVGSVGElement | null>(null)
const width = ref(800)
const height = ref(600)
// ── State interne ──────────────────────────────────────────────────────────
type SimNode = d3.SimulationNodeDatum & { id: number; nom: string; offre: string; besoin: string }
type SimLink = d3.SimulationLinkDatum<SimNode> & { score: number; mode: string }
let simulation: d3.Simulation<SimNode, SimLink> | null = null
let svgRoot: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
let gLinks: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
let gNodes: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
const isMobile = computed(() => width.value < 600)
const nodeRadius = computed(() => isMobile.value ? 22 : 28)
// ── Helpers ────────────────────────────────────────────────────────────────
function truncate(str: string, max = 10): string {
if (!str) return ''
return str.length > max ? str.slice(0, max - 1) + '…' : str
}
function buildNodes(): SimNode[] {
return props.fiches.map(f => ({
id: f.id,
nom: f.nom,
offre: f.offre,
besoin: f.besoin,
}))
}
function buildLinks(nodes: SimNode[]): SimLink[] {
if (!props.matches || props.matches.length === 0) return []
const nodeById = new Map(nodes.map(n => [n.id, n]))
return props.matches
.filter(m => nodeById.has(m.fromId) && nodeById.has(m.toId))
.map(m => ({
source: nodeById.get(m.fromId)!,
target: nodeById.get(m.toId)!,
score: m.score,
mode: m.mode,
}))
}
function linkColor(mode: string): string {
if (mode === 'solution') return '#22c55e'
if (mode === 'alliance') return '#f97316'
if (mode === 'surprise') return '#3b82f6'
return '#ccc'
}
// ── Drag handler ───────────────────────────────────────────────────────────
function makeDrag(sim: d3.Simulation<SimNode, SimLink>): d3.DragBehavior<SVGGElement, SimNode, SimNode> {
return d3.drag<SVGGElement, SimNode>()
.on('start', (event, d) => {
if (!event.active) sim.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) sim.alphaTarget(0)
d.fx = null
d.fy = null
})
}
// ── Initialisation SVG ─────────────────────────────────────────────────────
function initSvg() {
if (!svgEl.value) return
svgRoot = d3.select(svgEl.value)
.attr('width', width.value)
.attr('height', height.value)
svgRoot.selectAll('*').remove()
gLinks = svgRoot.append('g').attr('class', 'links')
gNodes = svgRoot.append('g').attr('class', 'nodes')
}
// ── Rebuild liens (hook pour M4) ───────────────────────────────────────────
let currentNodes: SimNode[] = []
let currentLinks: SimLink[] = []
function rebuildLinks() {
currentLinks = buildLinks(currentNodes)
if (!gLinks || !simulation) return
// .join() moderne D3 pour garantir le re-rendu complet
gLinks
.selectAll<SVGLineElement, SimLink>('line')
.data(currentLinks)
.join(
enter => enter.append('line'),
update => update,
exit => exit.remove()
)
.attr('stroke', d => linkColor(d.mode))
.attr('stroke-width', d => 1 + d.score * 3)
.attr('stroke-opacity', 0.7)
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
}
// ── Rendu complet ──────────────────────────────────────────────────────────
function render() {
if (!svgEl.value || props.fiches.length === 0) return
initSvg()
currentNodes = buildNodes()
currentLinks = buildLinks(currentNodes)
const r = nodeRadius.value
const fontSize = isMobile.value ? 10 : 12
// Liens
gLinks!
.selectAll<SVGLineElement, SimLink>('line')
.data(currentLinks)
.join('line')
.attr('stroke', d => linkColor(d.mode))
.attr('stroke-width', d => 1 + d.score * 3)
.attr('stroke-opacity', 0.7)
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
// Noeuds = groupe <g> par personne
const nodeGroups = gNodes!
.selectAll<SVGGElement, SimNode>('g.node')
.data(currentNodes, d => String(d.id))
.join('g')
.attr('class', 'node')
.style('cursor', 'pointer')
.call(makeDrag(simulation!) as any)
.on('click', (_event, d) => emit('select-fiche', d.id))
// Cercle principal
nodeGroups.append('circle')
.attr('r', r)
.attr('fill', '#ffffff')
.attr('stroke', '#1B4436')
.attr('stroke-width', 2)
// Label nom
nodeGroups.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('font-size', fontSize)
.attr('font-weight', '700')
.attr('fill', '#1a1a2e')
.attr('pointer-events', 'none')
.text(d => truncate(d.nom, 10))
// Pastille offre (haut-droite, vert)
nodeGroups.append('circle')
.attr('r', 6)
.attr('cx', r * 0.65)
.attr('cy', -r * 0.65)
.attr('fill', '#22c55e')
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
// Pastille besoin (bas-droite, bleu)
nodeGroups.append('circle')
.attr('r', 6)
.attr('cx', r * 0.65)
.attr('cy', r * 0.65)
.attr('fill', '#3b82f6')
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
// Tooltip SVG natif <title>
nodeGroups.append('title')
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
// Groupe label bulle (affiche si showLabels)
const labelGroups = nodeGroups.append('g')
.attr('class', 'label-bubble')
.attr('visibility', props.showLabels ? 'visible' : 'hidden')
// Fond bulle besoin (dessous du noeud)
labelGroups.append('rect')
.attr('class', 'bubble-besoin-bg')
.attr('x', -(r + 50))
.attr('y', r + 4)
.attr('width', 100)
.attr('height', 28)
.attr('rx', 6)
.attr('fill', '#eff6ff')
.attr('stroke', '#3b82f6')
.attr('stroke-width', 1)
// Texte besoin
labelGroups.append('text')
.attr('class', 'bubble-besoin-txt')
.attr('x', -(r) + 50)
.attr('y', r + 22)
.attr('text-anchor', 'middle')
.attr('font-size', 9)
.attr('fill', '#1e40af')
.attr('pointer-events', 'none')
.text(d => truncate(d.besoin, 18))
// Fond bulle offre (dessus du noeud)
labelGroups.append('rect')
.attr('class', 'bubble-offre-bg')
.attr('x', -(r + 50))
.attr('y', -(r + 32))
.attr('width', 100)
.attr('height', 28)
.attr('rx', 6)
.attr('fill', '#f0fdf4')
.attr('stroke', '#22c55e')
.attr('stroke-width', 1)
// Texte offre
labelGroups.append('text')
.attr('class', 'bubble-offre-txt')
.attr('x', -(r) + 50)
.attr('y', -(r + 14))
.attr('text-anchor', 'middle')
.attr('font-size', 9)
.attr('fill', '#166534')
.attr('pointer-events', 'none')
.text(d => truncate(d.offre, 18))
// Simulation
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
.id(d => d.id)
.distance(120)
.strength(0.3))
.force('charge', d3.forceManyBody<SimNode>().strength(-400))
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
.force('collide', d3.forceCollide<SimNode>().radius(r + 12))
.force('x', d3.forceX(width.value / 2).strength(0.05))
.force('y', d3.forceY(height.value / 2).strength(0.05))
.alphaDecay(0.02)
.on('tick', tick)
// Re-bind drag avec la nouvelle simulation
gNodes!.selectAll<SVGGElement, SimNode>('g.node')
.call(makeDrag(simulation) as any)
}
function tick() {
const r = nodeRadius.value
if (!gLinks || !gNodes) return
gLinks.selectAll<SVGLineElement, SimLink>('line')
.attr('x1', d => Math.max(r, Math.min(width.value - r, (d.source as SimNode).x ?? 0)))
.attr('y1', d => Math.max(r, Math.min(height.value - r, (d.source as SimNode).y ?? 0)))
.attr('x2', d => Math.max(r, Math.min(width.value - r, (d.target as SimNode).x ?? 0)))
.attr('y2', d => Math.max(r, Math.min(height.value - r, (d.target as SimNode).y ?? 0)))
gNodes.selectAll<SVGGElement, SimNode>('g.node')
.attr('transform', d => {
const x = Math.max(r, Math.min(width.value - r, d.x ?? 0))
const y = Math.max(r, Math.min(height.value - r, d.y ?? 0))
return `translate(${x},${y})`
})
}
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
watch(() => [props.matches, props.mode] as const, () => {
if (!simulation) return
rebuildLinks()
const newForce = d3.forceLink<SimNode, SimLink>(currentLinks)
.id(d => String(d.id))
.distance(120)
.strength(0.5)
simulation.force('link', newForce)
simulation.alpha(0.8).restart()
}, { deep: true })
// ── Watch showLabels ──────────────────────────────────────────────────────
watch(() => props.showLabels, (val) => {
if (!svgEl.value) return
d3.select(svgEl.value).selectAll('.label-bubble').attr('visibility', val ? 'visible' : 'hidden')
})
// ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
watch(() => props.fiches, () => {
if (simulation) {
simulation.stop()
simulation = null
}
render()
}, { deep: true })
// ── ResizeObserver ─────────────────────────────────────────────────────────
let ro: ResizeObserver | null = null
onMounted(() => {
if (!container.value) return
width.value = container.value.clientWidth || 800
height.value = container.value.clientHeight || 600
render()
ro = new ResizeObserver(() => {
if (!container.value) return
width.value = container.value.clientWidth || 800
height.value = container.value.clientHeight || 600
if (svgRoot) {
svgRoot.attr('width', width.value).attr('height', height.value)
}
if (simulation) {
simulation.force('center', d3.forceCenter(width.value / 2, height.value / 2))
simulation.alpha(0.3).restart()
}
})
ro.observe(container.value!)
})
onUnmounted(() => {
if (simulation) simulation.stop()
if (ro) ro.disconnect()
})
</script>
<style scoped>
.codev-graph-wrap {
width: 100%;
height: 70vh;
min-height: 320px;
position: relative;
background: var(--nav-bg, #fafafa);
border-radius: 12px;
overflow: hidden;
}
.codev-svg {
width: 100%;
height: 100%;
display: block;
}
/* ── Etat vide ── */
.empty-state {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
text-align: center;
}
.empty-msg {
font-size: 1.125rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
}
.empty-link {
font-size: 0.9rem;
font-weight: 600;
color: var(--nav-primary-solid, #1B4436);
text-decoration: none;
border: 1.5px solid var(--nav-primary-solid, #1B4436);
border-radius: 8px;
padding: 0.5rem 1.25rem;
transition: background 0.15s, color 0.15s;
}
.empty-link:hover {
background: var(--nav-primary-solid, #1B4436);
color: #fff;
}
/* ── Mobile ── */
@media (max-width: 600px) {
.codev-graph-wrap {
height: 65vh;
min-height: 260px;
border-radius: 8px;
}
}
</style>

View File

@@ -1,11 +1,6 @@
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
css: [
'~/assets/css/main.css',
'leaflet/dist/leaflet.css',
'leaflet.markercluster/dist/MarkerCluster.css',
'leaflet.markercluster/dist/MarkerCluster.Default.css',
],
css: ['~/assets/css/main.css'],
runtimeConfig: {
nocodbUrl: process.env.NOCODB_URL,
@@ -19,20 +14,16 @@ export default defineNuxtConfig({
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
codevTableId: '', // NUXT_CODEV_TABLE_ID
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
},
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
ssr: true,
vite: {
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
optimizeDeps: {
include: ['leaflet', 'leaflet.markercluster', 'd3'],
include: ['leaflet', 'leaflet.markercluster'],
},
// Éviter l'import SSR de Leaflet qui utilise window
ssr: {
noExternal: [],
},

459
package-lock.json generated
View File

@@ -10,7 +10,6 @@
"@headlessui/vue": "^1.7.23",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
"ioredis": "^5.3.2",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
@@ -5313,416 +5312,6 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/db0": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
@@ -5836,15 +5425,6 @@
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/delaunator": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -6900,18 +6480,6 @@
"node": ">=16.17.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -6987,15 +6555,6 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
@@ -9921,12 +9480,6 @@
"node": ">=0.10.0"
}
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -10042,12 +9595,6 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -10086,12 +9633,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",

View File

@@ -13,7 +13,6 @@
"@headlessui/vue": "^1.7.23",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
"ioredis": "^5.3.2",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",

View File

@@ -1,517 +1,39 @@
<template>
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- 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%;">
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
<IntentionBanner />
<!-- Filtres familles + hashtags -->
<HashtagFilter
:allHashtags="allHashtags"
:selectedHashtags="selectedHashtags"
:selectedFamille="selectedFamille"
@update:selectedHashtags="selectedHashtags = $event"
@update:selectedFamille="selectedFamille = $event"
/>
<!-- 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 class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
<div class="text-center max-w-md px-6">
<div
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
style="background: var(--nav-bg-alt);"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</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>
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">Agences Inspirantes</h1>
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
Cette section répertoriera les agences d'architecture qui incarnent une pratique engagée — écologie politique, auto-construction, architectures vernaculaires, sobriété.
</p>
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
Bientôt disponible
</p>
<NuxtLink
to="/"
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Retour à l'écosystème
</NuxtLink>
</div>
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Onglets desktop -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'metropole'
? '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>
<!-- Carte Métropole desktop -->
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;">
<ClientOnly>
<NavMapV2
ref="navMapRef"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-structure="onSelectStructure"
/>
<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
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
<!-- Carte Outre-mer desktop -->
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<template #fallback>
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
Chargement…
</div>
</template>
</ClientOnly>
</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>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + sheet swipable ── -->
<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 === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'metropole'"
>Métropolitain</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 mobile Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<ClientOnly>
<NavMapV2
ref="navMapMobileRef"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-structure="onSelectStructureMobile"
/>
<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 mobile Outre-mer -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<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 -->
<ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Bandeau intention mobile -->
<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);">
<label class="mobile-search-label" aria-label="Rechercher une structure">
<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="search"
type="search"
placeholder="Rechercher…"
class="mobile-search-input"
autocomplete="off"
/>
<button
v-if="search"
type="button"
class="mobile-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>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="mt-1 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;"
>Effacer les filtres</button>
</div>
<!-- Liste fiches mobile -->
<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 }} structure{{ 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="structure in filtered"
:key="structure.id"
class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === structure.id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectStructureMobile(structure.id)"
>
<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="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>
</div>
</div>
</MobileSheet>
</ClientOnly>
</div>
</main>
<!-- MODAL FICHE V2 (desktop) -->
<FicheModalV2
v-model="ficheModalOpen"
:structureId="ficheModalId"
:data="bifurcationData"
@update:structureId="ficheModalId = $event"
/>
<!-- 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"
@update:modelValue="chatbotOpen = $event"
@highlightOrgs="() => {}"
/>
</div>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
// ── Couleurs familles ──────────────────────────────────────────────────────
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
function familleColor(f: number): string {
return FAMILLE_COLORS[f] ?? '#888'
}
// ── État UI ────────────────────────────────────────────────────────────────
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 mobileMapView = ref<'metropole' | 'outremer'>('metropole')
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
// Filtres
const search = ref('')
const selectedFamille = ref<number | null>(null)
const selectedHashtags = ref<string[]>([])
// Refs cartes
const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null)
// ── Données V2 - JSON statique ─────────────────────────────────────────────
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
const pending = ref(true)
onMounted(async () => {
try {
bifurcationData.value = await $fetch<ReseauxBifurcationData>('/data/reseaux-bifurcation.json')
} catch (e) {
console.error('Erreur chargement reseaux-bifurcation.json', e)
} 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)
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
// OutremerMap attend le format Org legacy - on passe un tableau vide
const outremerOrgsLegacy = computed(() => [])
const selectedIdLegacyNum = computed(() => null)
// ── Sélection ─────────────────────────────────────────────────────────────
function onSelectStructure(id: string) {
selectedId.value = selectedId.value === id ? null : id
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
ficheModalId.value = id
ficheModalOpen.value = true
}
}
function onSelectStructureMobile(id: string) {
selectedId.value = id
ficheModalId.value = id
ficheModalOpen.value = true
}
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
useHead({ title: 'Agences Inspirantes — AEP (bientôt disponible)' })
</script>

View File

@@ -1,550 +0,0 @@
<template>
<div class="codev-carto">
<header class="carto-header">
<h1>Carto entraide</h1>
<p class="carto-subtitle">
<template v-if="pending">Chargement...</template>
<template v-else>
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
</template>
</p>
<NuxtLink to="/codev/qr" class="qr-link" title="QR Code">[ QR ]</NuxtLink>
</header>
<div class="codev-tabs">
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
</div>
<div v-if="tab === 'carto'">
<div class="show-labels-bar">
<button
type="button"
:class="{ active: showLabels }"
@click="showLabels = !showLabels"
>
{{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
</button>
</div>
<ClientOnly>
<CodevGraph
:fiches="fiches"
:matches="matches"
:mode="mode"
:show-labels="showLabels"
@select-fiche="onSelectFiche"
/>
<template #fallback>
<div class="graph-fallback">Chargement du graphe...</div>
</template>
</ClientOnly>
<!-- Bandeau info mode actif -->
<div v-if="mode !== 'none'" class="mode-banner">
<span>
Mode {{ MODE_LABELS[mode] }} actif -
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
</span>
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
</div>
<!-- Boutons matching -->
<div class="matching-controls">
<button
:class="{ active: mode === 'solution' }"
style="--mode-color: #22c55e"
@click="setMode('solution')"
type="button"
>
Solution
<span class="hint">besoin - compétence</span>
</button>
<button
:class="{ active: mode === 'alliance' }"
style="--mode-color: #f97316"
@click="setMode('alliance')"
type="button"
>
Alliance
<span class="hint">besoins partagés</span>
</button>
<button
v-if="mode !== 'none'"
class="reset"
@click="setMode('none')"
type="button"
>
Effacer
</button>
</div>
</div>
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
<div v-if="fiches.length === 0" class="list-empty">
Aucune fiche. <NuxtLink to="/codev/fiche">Ajouter la mienne</NuxtLink>
</div>
<div v-else class="annuaire-scroll">
<table class="annuaire-table">
<thead>
<tr>
<th class="col-nom">Prénom</th>
<th class="col-besoin">Besoin</th>
<th class="col-offre">Ce que j'offre</th>
<th v-if="isAdmin" class="col-actions"></th>
</tr>
</thead>
<tbody>
<tr v-for="f in fiches" :key="f.id" @click="navigateTo(`/codev/fiche?id=${f.id}`)" class="annuaire-row">
<td class="col-nom">{{ f.nom }}</td>
<td class="col-besoin">{{ f.besoin }}</td>
<td class="col-offre">{{ f.offre }}</td>
<td v-if="isAdmin" class="col-actions">
<button @click.stop="deleteFiche(f.id)" class="delete-btn" type="button" title="Supprimer">✕</button>
</td>
</tr>
</tbody>
</table>
</div>
<p class="annuaire-hint">Clique sur une ligne pour modifier la fiche</p>
</div>
<!-- FAB ajouter une fiche -->
<NuxtLink to="/codev/fiche" class="fab-add" title="Ajouter ma fiche" aria-label="Ajouter une fiche">
+
</NuxtLink>
<Transition name="sheet">
<div v-if="selectedFiche" class="bottom-sheet" @click.self="selectedFiche = null">
<div class="sheet-content">
<div class="sheet-handle"></div>
<div class="sheet-name">{{ selectedFiche.nom }}</div>
<div class="sheet-section">
<span class="sheet-label">Besoin</span>
<p class="sheet-text">{{ selectedFiche.besoin }}</p>
</div>
<div class="sheet-section">
<span class="sheet-label">Ce que j'apporte</span>
<p class="sheet-text">{{ selectedFiche.offre }}</p>
</div>
<div class="sheet-tags" v-if="selectedFiche.hashtags.length">
<span v-for="t in selectedFiche.hashtags" :key="t" class="sheet-tag">#{{ t }}</span>
</div>
<NuxtLink :to="`/codev/fiche?id=${selectedFiche.id}`" class="sheet-edit-btn">Modifier cette fiche</NuxtLink>
<button class="sheet-close" @click="selectedFiche = null" type="button">Fermer</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { CodevFiche, CodevMatch } from '~/types/codev'
import { computeMatches } from '~/utils/codev/matching'
useHead({ title: 'Carto - Co-developpement' })
const { data, pending, refresh } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
const fiches = computed(() => data.value?.list ?? [])
const matches = ref<CodevMatch[]>([])
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
const showLabels = ref(false)
const tab = ref<'carto' | 'annuaire'>('carto')
const selectedFiche = ref<CodevFiche | null>(null)
const isMobileView = typeof window !== 'undefined' ? window.innerWidth < 600 : false
const isAdmin = ref(false)
onMounted(async () => {
try {
const r = await $fetch<{ admin: boolean }>('/api/codev/me')
isAdmin.value = r.admin
} catch { isAdmin.value = false }
})
const MODE_LABELS: Record<string, string> = {
solution: 'Solution',
alliance: 'Alliance',
surprise: 'Surprise',
}
function setMode(newMode: 'none' | 'solution' | 'alliance' | 'surprise') {
mode.value = newMode
if (newMode === 'none') {
matches.value = []
} else {
matches.value = computeMatches(fiches.value, newMode)
}
}
function onSelectFiche(id: number) {
if (isMobileView) {
selectedFiche.value = fiches.value.find(f => f.id === id) ?? null
} else {
navigateTo(`/codev/fiche?id=${id}`)
}
}
async function deleteFiche(id: number) {
if (!confirm('Supprimer la fiche ?')) return
await $fetch(`/api/codev/fiches/${id}`, { method: 'DELETE' })
await refresh()
}
</script>
<style scoped>
.codev-carto {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
display: flex;
flex-direction: column;
padding: 1.25rem 1rem 2rem;
gap: 1rem;
max-width: 100%;
box-sizing: border-box;
}
/* ── En-tete ── */
.carto-header {
text-align: center;
padding-bottom: 0.5rem;
}
.carto-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.carto-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
}
/* ── Fallback ── */
.graph-fallback {
width: 100%;
height: 70vh;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted, #6b7280);
font-size: 0.9rem;
background: var(--nav-bg-alt, #f3f4f6);
border-radius: 12px;
}
/* ── Bandeau mode actif ── */
.mode-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.875rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
font-size: 0.875rem;
color: #166534;
flex-wrap: wrap;
}
.banner-clear {
font-size: 0.8rem;
font-weight: 600;
color: #166534;
background: transparent;
border: 1px solid #166534;
border-radius: 6px;
padding: 0.2rem 0.6rem;
cursor: pointer;
white-space: nowrap;
}
.banner-clear:hover {
background: #166534;
color: #fff;
}
/* ── Boutons matching ── */
.matching-controls {
position: sticky;
bottom: 0;
display: flex;
gap: 8px;
padding: 12px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-top: 1px solid #e5e7eb;
margin: 0 -1rem -2rem;
}
.matching-controls button {
flex: 1;
padding: 12px 8px;
border: 1px solid #d0d4dc;
border-radius: 8px;
background: white;
font-size: 14px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.matching-controls button .hint {
font-size: 11px;
color: #6b7280;
font-weight: normal;
}
.matching-controls button.active {
background: var(--mode-color, #1B4436);
color: white;
border-color: transparent;
}
.matching-controls button.active .hint {
color: rgba(255, 255, 255, 0.8);
}
.matching-controls button.reset {
flex: 0 0 auto;
padding: 12px 16px;
background: #f3f4f6;
border-color: #d0d4dc;
color: #374151;
font-size: 13px;
}
.matching-controls button.reset:hover {
background: #e5e7eb;
}
@media (max-width: 500px) {
.matching-controls {
display: grid;
grid-template-columns: repeat(2, 1fr);
margin: 0 -0.75rem -1.5rem;
}
.matching-controls button.reset {
grid-column: span 2;
}
}
/* ── Toggle besoins/offres ── */
.show-labels-bar {
display: flex;
justify-content: center;
margin-bottom: 8px;
}
.show-labels-bar button {
border: 1px solid #d0d4dc;
border-radius: 8px;
padding: 8px 16px;
background: white;
font-size: 13px;
cursor: pointer;
color: #374151;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.show-labels-bar button.active {
background: #1B4436;
color: white;
border-color: transparent;
}
/* ── FAB ajouter ── */
.fab-add {
position: fixed;
bottom: 80px;
right: 16px;
width: 48px;
height: 48px;
border-radius: 50%;
background: #1B4436;
color: white;
font-size: 28px;
font-weight: 300;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
z-index: 100;
transition: transform 0.15s, opacity 0.15s;
line-height: 1;
}
.fab-add:hover {
transform: scale(1.08);
opacity: 0.92;
}
/* ── Tabs ── */
.codev-tabs { display: flex; gap: 4px; background: #f3f4f6; border-radius: 10px; padding: 4px; }
.codev-tabs button { flex: 1; padding: 8px 4px; border: none; border-radius: 7px; background: transparent; font-size: 0.875rem; font-weight: 500; cursor: pointer; color: #6b7280; transition: all 0.15s; }
.codev-tabs button.active { background: white; color: #1a1a2e; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
/* ── List view ── */
.list-view { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
.list-card { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; }
.list-card-name { font-weight: 700; font-size: 0.95rem; color: #1a1a2e; }
.list-card-text { font-size: 0.875rem; color: #4b5563; margin: 0; line-height: 1.5; }
.list-card-link { font-size: 0.8rem; color: #1B4436; text-decoration: none; align-self: flex-end; }
.list-empty { text-align: center; color: #6b7280; font-size: 0.9rem; }
/* ── Bottom sheet ── */
.bottom-sheet { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 200; display: flex; align-items: flex-end; }
.sheet-content { background: white; border-radius: 16px 16px 0 0; padding: 16px 20px 32px; width: 100%; display: flex; flex-direction: column; gap: 12px; max-height: 80vh; overflow-y: auto; }
.sheet-handle { width: 36px; height: 4px; background: #d1d5db; border-radius: 2px; align-self: center; margin-bottom: 4px; }
.sheet-name { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
.sheet-section { display: flex; flex-direction: column; gap: 4px; }
.sheet-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
.sheet-text { font-size: 0.9rem; color: #374151; margin: 0; line-height: 1.5; }
.sheet-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.sheet-tag { font-size: 0.75rem; background: #f3f4f6; color: #374151; padding: 2px 8px; border-radius: 12px; }
.sheet-edit-btn { display: block; text-align: center; background: #1B4436; color: white; border-radius: 8px; padding: 12px; text-decoration: none; font-weight: 600; }
.sheet-close { background: transparent; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; color: #6b7280; cursor: pointer; font-size: 0.875rem; }
.sheet-enter-active, .sheet-leave-active { transition: opacity 0.2s; }
.sheet-enter-from, .sheet-leave-to { opacity: 0; }
/* ── QR link ── */
.qr-link {
font-size: 0.75rem;
color: #9ca3af;
text-decoration: none;
align-self: flex-end;
}
.qr-link:hover { color: #6b7280; }
/* ── Annuaire ── */
.annuaire-wrap {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.annuaire-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid #e5e7eb;
border-radius: 10px;
}
.annuaire-table {
width: 100%;
border-collapse: collapse;
min-width: 480px;
}
.annuaire-table thead tr {
background: #f9fafb;
border-bottom: 2px solid #e5e7eb;
}
.annuaire-table th {
padding: 10px 14px;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
white-space: nowrap;
}
.annuaire-table td {
padding: 12px 14px;
font-size: 0.875rem;
color: #374151;
vertical-align: top;
border-bottom: 1px solid #f3f4f6;
line-height: 1.5;
}
.annuaire-row {
cursor: pointer;
transition: background 0.12s;
}
.annuaire-row:hover { background: #f9fafb; }
.annuaire-row:last-child td { border-bottom: none; }
.col-nom {
position: sticky;
left: 0;
z-index: 2;
background: #ffffff;
font-weight: 600;
color: #1a1a2e !important;
white-space: nowrap;
min-width: 80px;
border-right: 2px solid #e5e7eb;
box-shadow: 2px 0 6px rgba(0,0,0,0.06);
}
.annuaire-row:hover .col-nom { background: #f9fafb; }
thead tr .col-nom { background: #f9fafb; z-index: 3; }
.col-besoin { min-width: 200px; max-width: 260px; }
.col-offre { min-width: 200px; max-width: 260px; }
.annuaire-hint {
font-size: 0.75rem;
color: #9ca3af;
text-align: center;
margin: 0;
}
.col-actions { width: 40px; text-align: center; }
.delete-btn {
background: transparent;
border: none;
cursor: pointer;
color: #ef4444;
font-size: 1rem;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.1s;
}
.delete-btn:hover { background: #fef2f2; }
/* ── Mobile ── */
@media (max-width: 600px) {
.codev-carto {
padding: 1rem 0.75rem 1.5rem;
}
.carto-header h1 {
font-size: 1.25rem;
}
}
</style>

View File

@@ -1,383 +0,0 @@
<template>
<div class="codev-demo">
<header class="demo-header">
<span class="demo-badge">DEMO</span>
<h1>Co-developpement - exemple</h1>
<p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p>
</header>
<div class="codev-tabs">
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
</div>
<div v-if="tab === 'carto'">
<ClientOnly>
<CodevGraph
:fiches="fiches"
:matches="matches"
:mode="mode"
/>
<template #fallback>
<div class="graph-fallback">Chargement du graphe...</div>
</template>
</ClientOnly>
<!-- Bandeau info mode actif -->
<div v-if="mode !== 'none'" class="mode-banner">
<span>
Mode {{ MODE_LABELS[mode] }} actif -
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
</span>
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
</div>
<!-- Boutons matching -->
<div class="matching-controls">
<button
:class="{ active: mode === 'solution' }"
style="--mode-color: #22c55e"
@click="setMode('solution')"
type="button"
>
Solution
<span class="hint">besoin - offre</span>
</button>
<button
:class="{ active: mode === 'alliance' }"
style="--mode-color: #f97316"
@click="setMode('alliance')"
type="button"
>
Alliance
<span class="hint">besoins partages</span>
</button>
<button
v-if="mode !== 'none'"
class="reset"
@click="setMode('none')"
type="button"
>
Effacer
</button>
</div>
</div>
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
<div class="annuaire-scroll">
<table class="annuaire-table">
<thead>
<tr>
<th class="col-nom">Prénom</th>
<th class="col-besoin">Besoin</th>
<th class="col-offre">Ce que j'offre</th>
</tr>
</thead>
<tbody>
<tr v-for="f in fiches" :key="f.id" class="annuaire-row">
<td class="col-nom">{{ f.nom }}</td>
<td class="col-besoin">{{ f.besoin }}</td>
<td class="col-offre">{{ f.offre }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { CodevFiche, CodevMatch } from '~/types/codev'
import { computeMatches } from '~/utils/codev/matching'
const tab = ref<'carto' | 'annuaire'>('carto')
// 10 fiches sans hashtags — textes enrichis pour que scoreDirect discrimine bien les 3 modes :
//
// Solution (scoreDirect besoinA vs offreB) :
// Sami(besoin vendre formation) -> Ines(offre vente formations) ✓
// Nael(besoin site web formation) -> Sami(offre developpement web) ✓
// Eva(besoin coaching vente) -> Ines(offre vente formations) ✓
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation tiers-lieux) ✓
//
// Alliance (besoins similaires) :
// Lea + Maya (coaching, lancer, offre) ✓
// Tom + Zoe (tiers-lieu, co-creer) ✓
// Sami + Kenji (vendre, formations) ✓
//
// Surprise (offres similaires) :
// Lea + Zoe (facilitation, groupes) ✓
// Tom + Roman (architecture) ✓
// Ines + Nael (marketing, formations) ✓
const FICHES_DEMO: CodevFiche[] = [
{
id: 1, nom: 'Lea',
besoin: 'Structurer et lancer mon offre de coaching professionnel cet automne',
offre: 'Facilitation de groupes et animation de cercles de parole',
hashtags: [],
created_at: '2026-05-08T10:00:00Z',
},
{
id: 2, nom: 'Sami',
besoin: 'Vendre ma formation en ligne et attirer mes premiers clients',
offre: 'Developpement web sur mesure, creation de sites et applications',
hashtags: [],
created_at: '2026-05-08T10:01:00Z',
},
{
id: 3, nom: 'Ines',
besoin: 'Ameliorer la facilitation de mes ateliers collaboratifs',
offre: 'Vente de formations en ligne et marketing pour formateurs',
hashtags: [],
created_at: '2026-05-08T10:02:00Z',
},
{
id: 4, nom: 'Tom',
besoin: 'Trouver des associes pour co-creer un tiers-lieu rural',
offre: 'Architecture bioclimatique et eco-construction pour tiers-lieux',
hashtags: [],
created_at: '2026-05-08T10:03:00Z',
},
{
id: 5, nom: 'Maya',
besoin: 'Creer et lancer mon offre de coaching en transition professionnelle',
offre: 'Accompagnement coaching de carriere et transitions professionnelles',
hashtags: [],
created_at: '2026-05-08T10:04:00Z',
},
{
id: 6, nom: 'Kenji',
besoin: 'Apprendre a vendre mes formations sans pression commerciale',
offre: 'Photographie professionnelle et direction artistique editoriale',
hashtags: [],
created_at: '2026-05-08T10:05:00Z',
},
{
id: 7, nom: 'Zoe',
besoin: 'Co-creer un tiers-lieu avec des porteurs de projet alignes',
offre: 'Facilitation de collectifs et animation en intelligence collective',
hashtags: [],
created_at: '2026-05-08T10:06:00Z',
},
{
id: 8, nom: 'Nael',
besoin: 'Creer un site web pour presenter et vendre ma formation',
offre: 'Strategie marketing digital et lancement de produits en ligne',
hashtags: [],
created_at: '2026-05-08T10:07:00Z',
},
{
id: 9, nom: 'Eva',
besoin: 'Lancer mon coaching avec une page de vente qui convertit',
offre: 'Ecriture longue forme, articles de fond et tribunes editoriales',
hashtags: [],
created_at: '2026-05-08T10:08:00Z',
},
{
id: 10, nom: 'Roman',
besoin: 'Ecrire de meilleurs articles pour mon blog et ma newsletter',
offre: 'Architecture technique et plans pour renovation energetique',
hashtags: [],
created_at: '2026-05-08T10:09:00Z',
},
]
const fiches = ref(FICHES_DEMO)
const matches = ref<CodevMatch[]>([])
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
const MODE_LABELS: Record<string, string> = {
solution: 'Solution',
alliance: 'Alliance',
surprise: 'Surprise',
}
useHead({ title: 'Demo - Co-developpement' })
function setMode(newMode: typeof mode.value) {
mode.value = newMode
if (newMode === 'none') {
matches.value = []
} else {
matches.value = computeMatches(fiches.value, newMode, 0.12)
}
}
</script>
<style scoped>
.codev-demo {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
display: flex;
flex-direction: column;
padding: 1.25rem 1rem 2rem;
gap: 1rem;
max-width: 100%;
box-sizing: border-box;
}
/* ── En-tete ── */
.demo-header {
text-align: center;
padding-bottom: 0.5rem;
}
.demo-badge {
display: inline-block;
background: #f97316;
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.demo-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
}
/* ── Fallback ── */
.graph-fallback {
width: 100%;
height: 70vh;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted, #6b7280);
font-size: 0.9rem;
background: var(--nav-bg-alt, #f3f4f6);
border-radius: 12px;
}
/* ── Bandeau mode actif ── */
.mode-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.875rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
font-size: 0.875rem;
color: #166534;
flex-wrap: wrap;
}
.banner-clear {
font-size: 0.8rem;
font-weight: 600;
color: #166534;
background: transparent;
border: 1px solid #166534;
border-radius: 6px;
padding: 0.2rem 0.6rem;
cursor: pointer;
white-space: nowrap;
}
.banner-clear:hover {
background: #166534;
color: #fff;
}
/* ── Boutons matching ── */
.matching-controls {
position: sticky;
bottom: 0;
display: flex;
gap: 8px;
padding: 12px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-top: 1px solid #e5e7eb;
margin: 0 -1rem -2rem;
}
.matching-controls button {
flex: 1;
padding: 12px 8px;
border: 1px solid #d0d4dc;
border-radius: 8px;
background: white;
font-size: 14px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.matching-controls button .hint {
font-size: 11px;
color: #6b7280;
font-weight: normal;
}
.matching-controls button.active {
background: var(--mode-color, #1B4436);
color: white;
border-color: transparent;
}
.matching-controls button.active .hint {
color: rgba(255, 255, 255, 0.8);
}
.matching-controls button.reset {
flex: 0 0 auto;
padding: 12px 16px;
background: #f3f4f6;
border-color: #d0d4dc;
color: #374151;
font-size: 13px;
}
.matching-controls button.reset:hover {
background: #e5e7eb;
}
@media (max-width: 500px) {
.matching-controls {
display: grid;
grid-template-columns: repeat(2, 1fr);
margin: 0 -0.75rem -1.5rem;
}
.matching-controls button.reset {
grid-column: span 2;
}
}
/* ── Mobile ── */
@media (max-width: 600px) {
.codev-demo {
padding: 1rem 0.75rem 1.5rem;
}
.demo-header h1 {
font-size: 1.25rem;
}
}
</style>

View File

@@ -1,415 +0,0 @@
<template>
<div class="fiche-page">
<div class="fiche-inner">
<!-- En-tête -->
<div class="fiche-header">
<NuxtLink to="/codev/carto" class="back-link"> Retour à la carte</NuxtLink>
<h1>{{ isEdit ? 'Modifier ma fiche' : 'Ma fiche' }}</h1>
<p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p>
</div>
<!-- Formulaire -->
<form class="fiche-form" @submit.prevent="submit" novalidate>
<!-- Nom -->
<div class="field-group">
<label for="nom">
Prénom <span class="required">*</span>
</label>
<input
id="nom"
v-model="form.nom"
type="text"
placeholder="Ex : Camille"
required
minlength="2"
maxlength="50"
:disabled="loading"
/>
</div>
<!-- Besoin -->
<div class="field-group">
<div class="label-row">
<label for="besoin">
Mon besoin actuel <span class="required">*</span>
</label>
<button type="button" class="tooltip-trigger" @click="toggleTip('besoin')" aria-label="C'est quoi un besoin ?">?</button>
</div>
<details v-if="activeTip === 'besoin'" class="tooltip-block" open>
<summary class="sr-only">Aide</summary>
<p>Un besoin, c'est ce qui te manque pour avancer. Ca peut etre concret (un coup de main sur un dossier) ou plus large (clarifier ou tu vas). Pas grave si c'est flou - la rencontre IRL aide a le preciser.</p>
</details>
<textarea
id="besoin"
v-model="form.besoin"
rows="3"
placeholder="Ex : J'ai besoin d'aide pour structurer mon offre de prestation"
required
minlength="5"
maxlength="300"
:disabled="loading"
/>
<span class="char-count" :class="{ 'char-warn': form.besoin.length > 260 }">
{{ form.besoin.length }}/300
</span>
</div>
<!-- Offre -->
<div class="field-group">
<div class="label-row">
<label for="offre">
Ce que j'offre a la communaute <span class="required">*</span>
</label>
<button type="button" class="tooltip-trigger" @click="toggleTip('offre')" aria-label="C'est quoi une offre ?">?</button>
</div>
<details v-if="activeTip === 'offre'" class="tooltip-block" open>
<summary class="sr-only">Aide</summary>
<p>Une offre, c'est une competence, une experience ou une qualite que tu peux partager. Ce que les autres viennent chercher chez toi naturellement.</p>
</details>
<textarea
id="offre"
v-model="form.offre"
rows="3"
placeholder="Ex : Je peux partager mon expérience en facilitation de groupe"
required
minlength="5"
maxlength="300"
:disabled="loading"
/>
<span class="char-count" :class="{ 'char-warn': form.offre.length > 260 }">
{{ form.offre.length }}/300
</span>
</div>
<!-- Hashtags -->
<div class="field-group">
<label for="hashtags">
Mots-clés
<span class="label-hint">(optionnel, 3 max, séparés par des virgules)</span>
</label>
<input
id="hashtags"
v-model="form.hashtagsRaw"
type="text"
placeholder="Ex : business, écriture, écologie"
maxlength="120"
:disabled="loading"
/>
</div>
<!-- Erreur serveur -->
<div v-if="error" class="server-error" role="alert">
{{ error }}
</div>
<!-- Bouton -->
<button type="submit" class="submit-btn" :disabled="loading">
{{ isEdit ? (loading ? 'Modification...' : 'Enregistrer les modifications') : (loading ? 'Envoi en cours...' : 'Ajouter ma fiche') }}
</button>
<NuxtLink to="/codev/carto" class="skip-link">
Voir la carte sans créer de fiche →
</NuxtLink>
</form>
</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const editId = computed(() => route.query.id ? Number(route.query.id) : null)
const isEdit = computed(() => editId.value !== null)
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
const error = ref('')
const loading = ref(false)
const activeTip = ref<'besoin' | 'offre' | null>(null)
useHead({ title: computed(() => isEdit.value ? 'Modifier ma fiche — Co-développement' : 'Ma fiche — Co-développement') })
onMounted(async () => {
if (!isEdit.value) return
try {
const fiche = await $fetch<any>(`/api/codev/fiches/${editId.value}`)
form.value.nom = fiche.nom
form.value.besoin = fiche.besoin
form.value.offre = fiche.offre
form.value.hashtagsRaw = fiche.hashtags.join(', ')
} catch {
error.value = 'Impossible de charger la fiche, elle a peut-etre ete supprimee.'
}
})
function toggleTip(field: 'besoin' | 'offre') {
activeTip.value = activeTip.value === field ? null : field
}
async function submit() {
error.value = ''
loading.value = true
try {
const hashtags = form.value.hashtagsRaw
.split(',')
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean)
.slice(0, 3)
const payload = {
nom: form.value.nom,
besoin: form.value.besoin,
offre: form.value.offre,
hashtags,
}
if (isEdit.value) {
await $fetch(`/api/codev/fiches/${editId.value}`, { method: 'PATCH', body: payload })
} else {
await $fetch('/api/codev/fiches', { method: 'POST', body: payload })
}
await navigateTo('/codev/carto')
} catch (e: any) {
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* ── Layout ── */
.fiche-page {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
padding: 1.5rem 1rem 4rem;
}
.fiche-inner {
max-width: 480px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
/* ── En-tête ── */
.back-link {
display: inline-block;
font-size: 0.875rem;
color: var(--nav-text-muted, #6b7280);
text-decoration: none;
margin-bottom: 0.75rem;
}
.back-link:hover {
color: var(--nav-primary-solid, #1B4436);
}
.fiche-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.fiche-lead {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
line-height: 1.5;
}
/* ── Formulaire ── */
.fiche-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Champ ── */
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text, #1a1a2e);
}
.label-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label-hint {
font-weight: 400;
font-size: 0.8rem;
color: var(--nav-text-muted, #6b7280);
margin-left: 0.25rem;
}
.required {
color: #c0392b;
}
.field-group input[type="text"],
.field-group input[type="password"],
.field-group textarea {
width: 100%;
padding: 0.75rem 0.875rem;
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 8px;
font-size: 1rem;
color: var(--nav-text, #1a1a2e);
background: var(--nav-surface, #ffffff);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group input:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid, #1B4436);
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
}
.field-group input:disabled,
.field-group textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.field-group textarea {
resize: vertical;
min-height: 80px;
}
.char-count {
font-size: 0.75rem;
color: var(--nav-text-muted, #6b7280);
text-align: right;
}
.char-warn {
color: #e67e22;
}
/* ── Tooltip ── */
.tooltip-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: var(--nav-surface, #ffffff);
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
color: var(--nav-text-muted, #6b7280);
cursor: pointer;
padding: 0;
line-height: 1;
flex-shrink: 0;
transition: border-color 0.15s, color 0.15s;
}
.tooltip-trigger:hover {
border-color: var(--nav-primary-solid, #1B4436);
color: var(--nav-primary-solid, #1B4436);
}
.tooltip-block {
background: var(--nav-surface, #ffffff);
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 8px;
padding: 0.75rem 0.875rem;
font-size: 0.85rem;
color: var(--nav-text-muted, #6b7280);
line-height: 1.5;
}
.tooltip-block p {
margin: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* ── Erreur serveur ── */
.server-error {
padding: 0.75rem 0.875rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
/* ── Bouton ── */
.submit-btn {
width: 100%;
padding: 0.875rem 1rem;
background: var(--nav-primary-solid, #1B4436);
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
margin-top: 0.25rem;
}
.submit-btn:hover:not(:disabled) {
opacity: 0.88;
}
.submit-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.skip-link {
display: block;
text-align: center;
font-size: 0.825rem;
color: var(--nav-text-muted, #9ca3af);
text-decoration: none;
margin-top: 0.5rem;
padding: 0.5rem;
}
.skip-link:hover { color: var(--nav-text, #1a1a2e); }
/* ── Responsive ── */
@media (max-width: 480px) {
.fiche-page {
padding: 1.25rem 1rem 3rem;
}
}
</style>

View File

@@ -1,217 +0,0 @@
<template>
<div class="lock-page">
<div class="lock-inner">
<div class="lock-header">
<h1>Co-développement</h1>
<p class="lock-subtitle">Entraide entre pairs</p>
<p class="lock-intro">Cet espace est un cercle. Pour entrer, il y a un mot.</p>
</div>
<form class="lock-form" @submit.prevent="submit" novalidate>
<div class="field-group">
<input
id="password"
v-model="password"
type="password"
placeholder="Mot de passe"
autocomplete="current-password"
required
:disabled="loading"
class="lock-input"
/>
</div>
<div v-if="error" class="lock-error" role="alert">
{{ error }}
</div>
<button type="submit" class="lock-btn" :disabled="loading || !password">
{{ loading ? 'Vérification...' : 'Entrer' }}
</button>
</form>
<div class="lock-footer">
<NuxtLink to="/codev/demo" class="demo-link">Voir l'exemple &rarr;</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const password = ref('')
const error = ref('')
const loading = ref(false)
useHead({ title: 'Co-développement Entraide entre pairs' })
async function submit() {
error.value = ''
loading.value = true
try {
await $fetch('/api/codev/auth', {
method: 'POST',
body: { password: password.value },
})
await navigateTo('/codev/fiche')
} catch (e: any) {
error.value = e?.statusMessage || 'Mauvais mot de passe'
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* ── Layout ── */
.lock-page {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
}
.lock-inner {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 2rem;
}
/* ── En-tête ── */
.lock-header {
text-align: center;
}
.lock-header h1 {
font-size: 1.75rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.lock-subtitle {
font-size: 1rem;
color: var(--nav-text-muted, #6b7280);
margin: 0 0 1rem;
}
.lock-intro {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
line-height: 1.5;
margin: 0;
font-style: italic;
}
/* ── Formulaire ── */
.lock-form {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.field-group {
display: flex;
flex-direction: column;
}
.lock-input {
width: 100%;
padding: 0.875rem 1rem;
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 8px;
font-size: 1rem;
color: var(--nav-text, #1a1a2e);
background: var(--nav-surface, #ffffff);
font-family: inherit;
text-align: center;
letter-spacing: 0.1em;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.lock-input:focus {
outline: none;
border-color: var(--nav-primary-solid, #1B4436);
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
}
.lock-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Erreur ── */
.lock-error {
padding: 0.625rem 0.875rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
text-align: center;
}
/* ── Bouton ── */
.lock-btn {
width: 100%;
padding: 0.875rem 1rem;
background: var(--nav-primary-solid, #1B4436);
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.lock-btn:hover:not(:disabled) {
opacity: 0.88;
}
.lock-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Pied de page ── */
.lock-footer {
text-align: center;
}
.demo-link {
font-size: 0.875rem;
color: var(--nav-text-muted, #6b7280);
text-decoration: none;
transition: color 0.15s;
}
.demo-link:hover {
color: var(--nav-primary-solid, #1B4436);
}
/* ── Responsive ── */
@media (max-width: 480px) {
.lock-page {
padding: 1.25rem 1rem 2.5rem;
align-items: flex-start;
padding-top: 3rem;
}
}
</style>

View File

@@ -1,94 +0,0 @@
<template>
<div class="qr-page">
<div class="qr-card">
<h1>Co-développement</h1>
<p class="qr-subtitle">Scanne pour rejoindre la session</p>
<img
:src="`https://api.qrserver.com/v1/create-qr-code/?size=280x280&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
alt="QR code aep.trans-former.fr/codev"
class="qr-img"
width="280"
height="280"
/>
<p class="qr-url">{{ APP_URL }}</p>
<p class="qr-password">Mot de passe : <strong>merci</strong></p>
<a :href="`https://api.qrserver.com/v1/create-qr-code/?size=600x600&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
download="codev-qr.png"
class="qr-download"
target="_blank"
>
Télécharger le QR code
</a>
</div>
</div>
</template>
<script setup lang="ts">
const APP_URL = 'https://aep.trans-former.fr/codev'
useHead({ title: 'QR Code — Co-développement' })
</script>
<style scoped>
.qr-page {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.qr-card {
background: white;
border-radius: 16px;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
max-width: 360px;
width: 100%;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
text-align: center;
}
.qr-card h1 {
font-size: 1.25rem;
font-weight: 700;
color: #1a1a2e;
margin: 0;
}
.qr-subtitle {
font-size: 0.9rem;
color: #6b7280;
margin: 0;
}
.qr-img {
border-radius: 8px;
border: 2px solid #e5e7eb;
}
.qr-url {
font-size: 0.8rem;
color: #9ca3af;
margin: 0;
font-family: monospace;
}
.qr-password {
font-size: 0.95rem;
color: #374151;
margin: 0;
}
.qr-download {
display: inline-block;
padding: 10px 20px;
background: #1B4436;
color: white;
border-radius: 8px;
text-decoration: none;
font-size: 0.875rem;
font-weight: 600;
transition: opacity 0.15s;
}
.qr-download:hover { opacity: 0.88; }
</style>

View File

@@ -72,6 +72,21 @@ const { data: org, pending, error } = await useFetch<Org>(`/api/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 ───────────────────────────
const commentRefreshTick = ref(0)

View File

@@ -1,48 +1,111 @@
<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">
<NavSidebar
:search="search"
:modeValue="territoireMode"
:echelle="echelle"
:fonctions="fonctions"
:territoire="territoire"
:echelleCount="echelleCount"
:fonctionCount="fonctionCount"
:territoireCount="territoireCount"
:resultCount="filtered.length"
: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"
<!-- 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%;">
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
<IntentionBanner />
<!-- Filtres familles + hashtags -->
<HashtagFilter
:allHashtags="allHashtags"
:selectedHashtags="selectedHashtags"
:selectedFamille="selectedFamille"
@update:selectedHashtags="selectedHashtags = $event"
@update:selectedFamille="selectedFamille = $event"
/>
<!-- 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>
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- Indicateur source dev -->
<div
v-if="dataSource === 'seed'"
class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
style="background: var(--nav-accent); color: var(--nav-text);"
>
Mode dev données seed
</div>
<!-- VUE DESKTOP : Onglets Métropole / Outre-mer -->
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Barre onglets desktop -->
<!-- Onglets desktop -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="px-5 py-2 text-sm font-medium transition-colors"
@@ -58,47 +121,82 @@
: '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>
<!-- Carte Métropole desktop -->
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;">
<ClientOnly>
<NavMap
<NavMapV2
ref="navMapRef"
:orgs="metropoleOrgs"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-org="onSelectOrg"
@select-structure="onSelectStructure"
/>
<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>
<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 @highlightOrgs="onHighlightOrgs" />
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
<!-- Carte Outre-mer desktop -->
<div v-show="desktopMapView === 'outremer'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex-1 overflow-y-auto">
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<template #fallback>
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
Chargement…
</div>
</template>
</ClientOnly>
</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>
<OutremerMap
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectOrg"
<GraphView
:data="bifurcationData"
:allHashtags="allHashtags"
:active="desktopMapView === 'graphe'"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">Chargement</div>
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
Chargement du graphe...
</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
</div>
<!-- VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable -->
<!-- Onglets Métropolitain / Outre-mer -->
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + sheet swipable ── -->
<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"
@@ -117,34 +215,30 @@
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte Métropole -->
<!-- Carte mobile Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<ClientOnly>
<NavMap
<NavMapV2
ref="navMapMobileRef"
:orgs="metropoleOrgs"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-org="onSelectOrgMobile"
@select-structure="onSelectStructureMobile"
/>
<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…
</div>
</template>
</ClientOnly>
</div>
<!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
<!-- Carte mobile Outre-mer -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectOrgMobile"
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
@@ -154,81 +248,65 @@
</ClientOnly>
</div>
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
<!-- Bottom sheet swipable -->
<ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Barre recherche -->
<!-- Bandeau intention mobile -->
<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);">
<label class="mobile-search-label" aria-label="Rechercher une organisation">
<label class="mobile-search-label" aria-label="Rechercher une structure">
<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"
v-model="search"
type="search"
placeholder="Rechercher…"
class="mobile-search-input"
autocomplete="off"
@input="onSearch(mobileSearch)"
/>
<button
v-if="mobileSearch"
v-if="search"
type="button"
class="mobile-search-clear"
aria-label="Effacer"
@click.stop="mobileSearch = ''; onSearch('')"
@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>
<!-- 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
v-if="hasActiveFilters"
@click="resetFilters"
class="mt-2 text-xs"
class="mt-1 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;"
> Effacer les filtres</button>
>Effacer les filtres</button>
</div>
<!-- Compteur + Liste fiches -->
<!-- Liste fiches mobile -->
<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' : '' }}
{{ filtered.length }} structure{{ 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
@@ -241,46 +319,36 @@
</div>
<div class="space-y-2">
<div
v-for="org in filtered"
:key="org.Id"
v-for="structure in filtered"
:key="structure.id"
class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === org.Id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
:style="selectedId === structure.id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectOrgMobile(org.Id)"
@click="onSelectStructureMobile(structure.id)"
>
<div class="flex items-start justify-between gap-2">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
<span
v-if="org.echelle"
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 }}
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>
</div>
</div>
</MobileSheet>
</ClientOnly>
</div>
</main>
<!-- MODAL FICHE (desktop) -->
<FicheModal
<!-- MODAL FICHE V2 (desktop) -->
<FicheModalV2
v-model="ficheModalOpen"
:orgId="ficheModalId"
:structureId="ficheModalId"
:data="bifurcationData"
@update:structureId="ficheModalId = $event"
/>
<!-- BOUTON CHATBOT FLOTTANT (mobile) -->
@@ -309,269 +377,141 @@
<ChatbotSheet
:modelValue="chatbotOpen"
@update:modelValue="chatbotOpen = $event"
@highlightOrgs="onHighlightOrgs"
@highlightOrgs="() => {}"
/>
</div>
</template>
<script setup lang="ts">
import type { Org } from '~/types/org'
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
// ── URL query params sync ─────────────────────────────────────────────────
const route = useRoute()
const router = useRouter()
const search = ref<string>((route.query.q as string) ?? '')
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'
)
const desktopMapView = ref<'metropole' | 'outremer'>('metropole')
const selectedId = ref<number | null>(null)
const chatbotOpen = ref(false)
const ficheModalOpen = ref(false)
const ficheModalId = ref<number | null>(null)
const mobileMapView = ref<'metropole' | 'outremer'>('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)
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
// 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)
// ── Couleurs familles ──────────────────────────────────────────────────────
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
const mobileSearch = ref<string>((route.query.q as string) ?? '')
function familleColor(f: number): string {
return FAMILLE_COLORS[f] ?? '#888'
}
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
// ── État UI ────────────────────────────────────────────────────────────────
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 mobileMapView = ref<'metropole' | 'outremer'>('metropole')
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
// Filtres
const search = ref('')
const selectedFamille = ref<number | null>(null)
const selectedHashtags = ref<string[]>([])
// Refs cartes
const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null)
// Sync URL <-> état filtres
function syncUrl() {
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'
router.replace({ query: Object.keys(q).length ? q : undefined })
// ── Données V2 - JSON statique ─────────────────────────────────────────────
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
const pending = ref(true)
onMounted(async () => {
try {
bifurcationData.value = await $fetch<ReseauxBifurcationData>('/data/reseaux-bifurcation.json')
} catch (e) {
console.error('Erreur chargement reseaux-bifurcation.json', e)
} 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 = []
}
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
function storeFiltersForBack() {
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)
}
// Structures métropole (pays != DOM-TOM, et avec coordonnées)
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
function onMode(v: string) { territoireMode.value = v; syncUrl(); storeFiltersForBack() }
function onEchelle(v: string[]) { echelle.value = v; syncUrl(); storeFiltersForBack() }
function onFonctions(v: string[]) { fonctions.value = v; syncUrl(); storeFiltersForBack() }
function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); storeFiltersForBack() }
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
// OutremerMap attend le format Org legacy - on passe un tableau vide
const outremerOrgsLegacy = computed(() => [])
const selectedIdLegacyNum = computed(() => null)
function onSelectOrg(id: number) {
// ── Sélection ─────────────────────────────────────────────────────────────
function onSelectStructure(id: string) {
selectedId.value = selectedId.value === id ? null : id
// Desktop : ouvrir le modal fiche
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
ficheModalId.value = id
ficheModalOpen.value = true
}
}
// Tap card mobile → ouvre la fiche détaillée
function onSelectOrgMobile(id: number) {
function onSelectStructureMobile(id: string) {
selectedId.value = id
storeFiltersForBack()
router.push(`/fiche/${id}`)
ficheModalId.value = id
ficheModalOpen.value = true
}
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' })
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
</script>

170
pages/pratique/[id].vue Normal file
View File

@@ -0,0 +1,170 @@
<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

@@ -0,0 +1,564 @@
<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>

833
pages/proposer-pratique.vue Normal file
View File

@@ -0,0 +1,833 @@
<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>

View File

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

View File

@@ -0,0 +1,834 @@
[
{
"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
}
]

127
scripts/vectorize-v2.cjs Normal file
View File

@@ -0,0 +1,127 @@
// 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

@@ -0,0 +1,304 @@
/**
* 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,46 +0,0 @@
import { z } from 'zod'
const AuthSchema = z.object({
password: z.string().min(1).max(100),
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const parsed = AuthSchema.safeParse(body)
if (!parsed.success) {
throw createError({ statusCode: 422, statusMessage: 'Mot de passe invalide' })
}
const config = useRuntimeConfig()
const expected = config.codevPassword || 'merci'
const isAdmin = parsed.data.password.trim().toLowerCase() === (config.codevAdminPassword || 'admin2026').trim().toLowerCase()
const isUser = parsed.data.password.trim().toLowerCase() === expected.trim().toLowerCase()
if (!isAdmin && !isUser) {
throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
}
// Cookie session (user + admin)
setCookie(event, 'codev_session', 'ok', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24, // 24h
path: '/',
})
// Cookie admin si mot de passe admin
if (isAdmin) {
setCookie(event, 'codev_admin', 'ok', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24, // 24h
path: '/',
})
}
return { status: 200, ok: true, admin: isAdmin }
})

View File

@@ -1,31 +0,0 @@
import type { CodevFiche } from '~/types/codev'
export default defineEventHandler(async (event): Promise<{ list: CodevFiche[] }> => {
const config = useRuntimeConfig()
const tableId = config.codevTableId
if (!tableId) {
throw createError({ statusCode: 500, message: 'codevTableId non configuré' })
}
const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records?sort=created_at&limit=200`
const data: any = await $fetch(url, {
headers: { 'xc-token': config.nocodbToken },
}).catch(() => ({ list: [] }))
// Mapper chaque record NocoDB vers CodevFiche
const list: CodevFiche[] = (data?.list ?? []).map((r: any) => ({
id: r.Id ?? r.id,
nom: r.nom || '',
besoin: r.besoin || '',
offre: r.offre || '',
hashtags: (r.hashtags || '')
.split(',')
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean),
created_at: r.created_at || r.CreatedAt || new Date().toISOString(),
}))
return { list }
})

View File

@@ -1,63 +0,0 @@
import { z } from 'zod'
const FicheSchema = z.object({
nom: z.string().min(2).max(50).trim(),
besoin: z.string().min(5).max(300).trim(),
offre: z.string().min(5).max(300).trim(),
hashtags: z.array(z.string().max(30)).max(3).default([]),
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const parsed = FicheSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation échouée',
data: parsed.error.flatten().fieldErrors,
})
}
const config = useRuntimeConfig()
const tableId = config.codevTableId
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
const payload = {
nom: parsed.data.nom,
besoin: parsed.data.besoin,
offre: parsed.data.offre,
hashtags: parsed.data.hashtags
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean)
.slice(0, 3)
.join(','),
created_at: new Date().toISOString(),
}
// NocoDB v1 endpoint pour INSERT (cf. submit/index.post.ts pour le pattern)
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}`
let inserted: any
try {
inserted = await $fetch(insertUrl, {
method: 'POST',
headers: {
'xc-token': config.nocodbToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
} catch (e: any) {
console.error('[codev/fiches.post] NocoDB insert error:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur serveur, réessaie',
})
}
return {
status: 201,
id: inserted?.Id ?? inserted?.id ?? null,
}
})

View File

@@ -1,25 +0,0 @@
export default defineEventHandler(async (event) => {
// Vérif cookie admin
const adminCookie = getCookie(event, 'codev_admin')
if (adminCookie !== 'ok') {
throw createError({ statusCode: 403, statusMessage: 'Accès refusé' })
}
const config = useRuntimeConfig()
const tableId = config.codevTableId
const id = getRouterParam(event, 'id')
if (!tableId || !id) {
throw createError({ statusCode: 400, message: 'Parametre manquant' })
}
await $fetch(`${config.nocodbUrl}/api/v2/tables/${tableId}/records`, {
method: 'DELETE',
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ Id: Number(id) }),
}).catch(() => {
throw createError({ statusCode: 502, statusMessage: 'Erreur suppression' })
})
return { status: 200, ok: true }
})

View File

@@ -1,34 +0,0 @@
import type { CodevFiche } from '~/types/codev'
export default defineEventHandler(async (event): Promise<CodevFiche> => {
const config = useRuntimeConfig()
const tableId = config.codevTableId
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
const id = getRouterParam(event, 'id')
if (!tableId || !id) {
throw createError({ statusCode: 400, message: 'Parametre manquant' })
}
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
const r: any = await $fetch(url, {
headers: { 'xc-token': config.nocodbToken },
}).catch(() => null)
if (!r) {
throw createError({ statusCode: 404, message: 'Fiche introuvable' })
}
return {
id: r.Id ?? r.id,
nom: r.nom || '',
besoin: r.besoin || '',
offre: r.offre || '',
hashtags: (r.hashtags || '')
.split(',')
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean),
created_at: r.created_at || r.CreatedAt || '',
}
})

View File

@@ -1,59 +0,0 @@
import { z } from 'zod'
const PatchSchema = z.object({
nom: z.string().min(2).max(50).trim(),
besoin: z.string().min(5).max(300).trim(),
offre: z.string().min(5).max(300).trim(),
hashtags: z.array(z.string().max(30)).max(3).default([]),
})
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const tableId = config.codevTableId
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
const id = getRouterParam(event, 'id')
const body = await readBody(event)
if (!tableId || !id) {
throw createError({ statusCode: 400, message: 'Parametre manquant' })
}
const parsed = PatchSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation echouee',
data: parsed.error.flatten().fieldErrors,
})
}
const payload = {
nom: parsed.data.nom,
besoin: parsed.data.besoin,
offre: parsed.data.offre,
hashtags: parsed.data.hashtags
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean)
.slice(0, 3)
.join(','),
}
// NocoDB v1 PATCH par Id
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
try {
await $fetch(url, {
method: 'PATCH',
headers: {
'xc-token': config.nocodbToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
} catch (e: any) {
console.error('[codev/fiches.patch] NocoDB patch error:', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur' })
}
return { status: 200, ok: true }
})

View File

@@ -1,5 +0,0 @@
export default defineEventHandler((event) => {
const admin = getCookie(event, 'codev_admin') === 'ok'
const session = getCookie(event, 'codev_session') === 'ok'
return { admin, session }
})

View File

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,117 @@
// 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,
}
})

View File

@@ -1,21 +0,0 @@
// Middleware server Nuxt — protection des routes /codev/fiche et /codev/carto
// Laisse passer /codev (lock screen), /codev/demo et toutes les routes /api/*
export default defineEventHandler((event) => {
const url = getRequestURL(event)
const path = url.pathname
// Seulement les routes sous /codev/
if (!path.startsWith('/codev/')) return
// Routes publiques : /codev/demo et /codev/qr (et sous-routes éventuelles)
if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return
if (path === '/codev/qr' || path.startsWith('/codev/qr/')) return
// Vérification cookie
const session = getCookie(event, 'codev_session')
if (session === 'ok') return
// Non authentifié -> redirect vers /codev (lock screen)
return sendRedirect(event, '/codev', 302)
})

View File

@@ -1,18 +0,0 @@
export interface CodevFiche {
id: number
nom: string
besoin: string
offre: string
hashtags: string[] // parsé depuis CSV NocoDB
created_at: string // ISO
}
export interface CodevMatch {
fromId: number
toId: number
score: number // 0-1
mode: 'solution' | 'alliance' | 'surprise'
// solution : fromId.besoin matche toId.offre (orienté)
// alliance : symétrique sur besoin
// surprise : symétrique sur offre
}

69
types/pratique.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* 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',
}

View File

@@ -1,106 +0,0 @@
import type { CodevFiche, CodevMatch } from '~/types/codev'
const STOP_WORDS_FR = new Set([
'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'au', 'aux',
'et', 'ou', 'mais', 'donc', 'car', 'ni', 'or',
'a', 'en', 'pour', 'par', 'sur', 'avec', 'sans', 'dans', 'sous',
'je', 'tu', 'il', 'elle', 'on', 'nous', 'vous', 'ils', 'elles',
'mon', 'ma', 'mes', 'ton', 'ta', 'tes', 'son', 'sa', 'ses',
'notre', 'nos', 'votre', 'vos', 'leur', 'leurs',
'ce', 'cet', 'cette', 'ces', 'qui', 'que', 'quoi', 'dont',
'est', 'sont', 'etre', 'ai', 'as', 'avoir',
'pas', 'plus', 'moins', 'tres', 'aussi', 'bien', 'tout', 'tous',
'me', 'te', 'se', 'lui', 'leur', 'y',
])
function tokenize(text: string): Set<string> {
if (!text) return new Set()
const tokens = text
.toLowerCase()
.replace(/[.,;:!?()'"\-/]/g, ' ')
.split(/\s+/)
.filter((t) => t.length >= 3 && !STOP_WORDS_FR.has(t))
return new Set(tokens)
}
function jaccard(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0
let inter = 0
for (const x of a) if (b.has(x)) inter++
const union = a.size + b.size - inter
return union === 0 ? 0 : inter / union
}
function score(textA: string, hashtagsA: string[], textB: string, hashtagsB: string[]): number {
const tagsA = new Set(hashtagsA.map((h) => h.toLowerCase()))
const tagsB = new Set(hashtagsB.map((h) => h.toLowerCase()))
if (tagsA.size > 0 && tagsB.size > 0) {
return jaccard(tagsA, tagsB)
}
return jaccard(tokenize(textA), tokenize(textB))
}
// scoreDirect tokenise TOUJOURS les textes, ignore les hashtags
// Utilise pour matchSolution : besoin vs offre doivent etre compares par leur contenu reel
function scoreDirect(textA: string, textB: string): number {
return jaccard(tokenize(textA), tokenize(textB))
}
export function matchSolution(fiches: CodevFiche[], threshold = 0.18): CodevMatch[] {
const matches: CodevMatch[] = []
for (const a of fiches) {
for (const b of fiches) {
if (a.id === b.id) continue
// Solution : on compare le TEXTE besoin de A avec le TEXTE offre de B
// On ignore les hashtags pour differencier besoin et offre
const s = scoreDirect(a.besoin, b.offre)
if (s >= threshold) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' })
}
}
}
return matches
}
export function matchAlliance(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
const matches: CodevMatch[] = []
for (let i = 0; i < fiches.length; i++) {
for (let j = i + 1; j < fiches.length; j++) {
const a = fiches[i], b = fiches[j]
// Alliance : besoins similaires — on compare hashtags si presents, sinon textes
const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags)
if (s >= threshold) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' })
}
}
}
return matches
}
export function matchSurprise(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
const matches: CodevMatch[] = []
for (let i = 0; i < fiches.length; i++) {
for (let j = i + 1; j < fiches.length; j++) {
const a = fiches[i], b = fiches[j]
// Surprise : offres similaires
const s = score(a.offre, a.hashtags, b.offre, b.hashtags)
if (s >= threshold) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'surprise' })
}
}
}
return matches
}
export function computeMatches(
fiches: CodevFiche[],
mode: 'solution' | 'alliance' | 'surprise',
threshold?: number,
): CodevMatch[] {
switch (mode) {
case 'solution': return matchSolution(fiches, threshold)
case 'alliance': return matchAlliance(fiches, threshold)
case 'surprise': return matchSurprise(fiches, threshold)
}
}