Files
nav-carte/pages/trouver-du-taf.vue
Jules Neny f5732bf336 feat(mobile+UX): refonte hamburger, pop-ups Mission, Manifeste, fixes mobile
Hamburger:
- Ajout Jobs, Manifeste, Soutenir
- Ré-ordonnancement (cartes/RAG/Codev en haut, ressources en bas)

Pop-ups Mission:
- MissionPopup générique (slot, props title/ctaLabel/storageKey)
- Auto-show 1ère visite Carte 1 (Entraide) et Carte 2 (Réseaux AEP)
- Bouton (i) flottant pour rouvrir

Pages:
- /manifeste : nouvelle page (texte version page-carto-V1)
- /a-propos : section 1 retirée (devient pop-up Carte 1) + scroll latéral fixé
- /agences : 3e onglet "Graphe" sur mobile + labels structures sur GraphView
- /trouver-du-taf : intro pédagogique repliable (onglets / tags / 5 axes),
  filtres mobile repliables, "Plateformes B2C" → "Pour archi indépendants"

Mobile UX:
- FAB coeur jaune Soutenir retiré (BandeauBas) — accessible via hamburger
- FicheModal/V2 : décalage top:76px sur mobile pour ne plus mordre header
- Logo header : "Architecture d'Écologie / Politique" en clair (2 lignes)

Cause racine résolue:
- /api/chatbot-reseaux n'avait jamais été déployé → 404 en prod avant ce build

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:58:42 +02:00

1017 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="taff-page" style="background: var(--nav-bg); min-height: 100%;">
<!-- Intro -->
<div class="taff-header">
<div class="taff-header-inner">
<h1 class="taff-title">Trouver du taf en archi</h1>
<p class="taff-subtitle">
Annuaire critique des plateformes de mise en relation et d'appels d'offres,
pour les <strong>architectes indépendants</strong> 70&nbsp;% de la profession et sa part la plus précaire économiquement.
</p>
<!-- Pédagogie : ce qu'on évalue -->
<details class="taff-pedago" open>
<summary class="taff-pedago-summary">
<span>Comment on lit cette carte&nbsp;?</span>
<svg class="taff-pedago-chevron" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 12 15 18 9"/>
</svg>
</summary>
<div class="taff-pedago-body">
<div class="taff-pedago-block">
<h3>Deux onglets, deux mondes</h3>
<ul>
<li><strong>Pour archi indépendants</strong> (B2C)&nbsp;: plateformes privées qui te mettent en relation avec des particuliers (Houzz, Architoo, etc.). Modèle économique = elles te facturent l'accès aux leads.</li>
<li><strong>Appels d'offres publics</strong>&nbsp;: marchés publics (BOAMP, JOUE, plateformes territoriales). Procédure réglementée, gros volumes, accès aux MOE publics.</li>
</ul>
</div>
<div class="taff-pedago-block">
<h3>Trois étiquettes, trois niveaux de confiance</h3>
<ul>
<li><span class="taff-pedago-tag" style="background: rgba(90,122,74,0.12); color: #3d5534;">✅ Recommandé</span> validé par AEP — pratiques alignées avec une éthique architecturale (rémunération correcte, transparence, écologie, qualité du matching)</li>
<li><span class="taff-pedago-tag" style="background: rgba(196,164,114,0.15); color: #7a5f2a;">⚠️ Sous réserve</span> utilisable mais avec vigilance — un ou plusieurs points faibles à connaître avant de t'inscrire</li>
<li><span class="taff-pedago-tag" style="background: rgba(168,93,62,0.12); color: #7a3322;"> À éviter</span> pratiques contraires à l'intérêt des architectes (commissions abusives, dumping, appâtage, etc.)</li>
</ul>
</div>
<div class="taff-pedago-block">
<h3>Cinq axes d'évaluation</h3>
<p class="taff-pedago-axes">
<strong>Rémunération</strong> (commissions, leads payants) ·
<strong>Transparence</strong> (CGV, modèle économique) ·
<strong>Pratiques pro</strong> (respect du Code de déontologie) ·
<strong>Écologie</strong> (incitation à la réno, matériaux) ·
<strong>Qualité du matching</strong> (filtres, pertinence des leads).
</p>
</div>
</div>
</details>
<div class="taff-stats">
<span class="taff-stat" style="color: #3d5534;">
<span class="taff-stat-dot" style="background: #5a7a4a;"></span>
{{ stats.recommande }} Recommandé{{ stats.recommande > 1 ? 's' : '' }} AEP
</span>
<span class="taff-stat" style="color: #7a5f2a;">
<span class="taff-stat-dot" style="background: #c4a472;"></span>
{{ stats.sous_reserve }} Sous réserve
</span>
<span class="taff-stat" style="color: #7a3322;">
<span class="taff-stat-dot" style="background: #a85d3e;"></span>
{{ stats.a_eviter }} À éviter
</span>
</div>
</div>
</div>
<!-- Filtres -->
<div class="taff-filters-bar" :class="{ 'taff-filters-bar--collapsed': !filtersExpanded }">
<div class="taff-filters-inner">
<!-- Onglets B2C / AO publics -->
<div class="taff-tabs">
<button
type="button"
class="taff-tab"
:class="{ 'taff-tab--active': activeTab === 'b2c' }"
@click="activeTab = 'b2c'; resetFilters()"
>
Pour archi indépendants
<span class="taff-tab-count">{{ b2cCount }}</span>
</button>
<button
type="button"
class="taff-tab"
:class="{ 'taff-tab--active': activeTab === 'ao' }"
@click="activeTab = 'ao'; resetFilters()"
>
Appels d'offres publics
<span class="taff-tab-count">{{ aoCount }}</span>
</button>
<!-- Toggle filtres mobile -->
<button
type="button"
class="taff-filters-toggle"
:aria-expanded="filtersExpanded"
@click="filtersExpanded = !filtersExpanded"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="4" y1="6" x2="20" y2="6"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="10" y1="18" x2="14" y2="18"/>
</svg>
<span>Filtres</span>
<span v-if="activeFilterCount" class="taff-filters-toggle-badge">{{ activeFilterCount }}</span>
<svg
width="11" height="11" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round"
:style="{ transform: filtersExpanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.18s' }"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
<div class="taff-filters-collapsible">
<!-- Filtres tag global -->
<div class="taff-filter-group">
<button
v-for="t in TAG_OPTIONS"
:key="t.value"
type="button"
class="taff-filter-btn"
:class="{ 'taff-filter-btn--active': filterTag === t.value }"
:style="filterTag === t.value
? `background: ${t.bg}; color: ${t.text}; border-color: ${t.accent};`
: ''"
@click="filterTag = filterTag === t.value ? '' : t.value"
>
{{ t.emoji }} {{ t.label }}
</button>
</div>
<!-- Filtre secteur (B2C uniquement) -->
<div v-if="activeTab === 'b2c'" class="taff-filter-group">
<button
v-for="s in SECTEUR_OPTIONS"
:key="s.value"
type="button"
class="taff-filter-btn"
:class="{ 'taff-filter-btn--active': filterSecteur === s.value }"
@click="filterSecteur = filterSecteur === s.value ? '' : s.value"
>
{{ s.label }}
</button>
</div>
<!-- Recherche -->
<label class="taff-search">
<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="taff-search-input"
autocomplete="off"
/>
<button v-if="search" type="button" @click.stop="search = ''" class="taff-search-clear" aria-label="Effacer">
<svg width="12" height="12" 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>
<!-- Compteur + reset -->
<div class="flex items-center gap-3 ml-auto">
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
</span>
<button
v-if="hasFilters"
type="button"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
@click="resetFilters"
>Effacer</button>
</div>
</div><!-- /.taff-filters-collapsible -->
</div>
</div>
<!-- ── Grille ─────────────────────────────────────────────────── -->
<div class="taff-grid-wrap">
<div v-if="filtered.length === 0" class="taff-empty">
<p style="color: var(--nav-text-muted);">Aucune plateforme ne correspond à ces filtres.</p>
<button type="button" class="taff-reset-btn" @click="resetFilters">Réinitialiser les filtres</button>
</div>
<div v-else class="taff-grid">
<PlatformeTaffCard
v-for="p in filtered"
:key="p.id"
:plateforme="p"
@open="openModal"
/>
</div>
</div>
<!-- ── Chatbot FAB ───────────────────────────────────────────── -->
<Teleport to="body">
<!-- Bouton flottant -->
<button
v-if="!chatOpen"
class="taff-fab"
@click="chatOpen = true"
aria-label="Ouvrir le guide IA — Quel plateforme me convient ?"
title="Guide IA — Je t'aide à choisir"
>
<svg width="20" height="20" 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 class="taff-fab-label">Guide IA</span>
</button>
<!-- Panel chatbot -->
<Transition name="taff-chat">
<div v-if="chatOpen" class="taff-chat-panel" role="dialog" aria-modal="true" aria-label="Guide IA Choisir sa plateforme">
<!-- Header panel -->
<div class="taff-chat-header">
<div class="flex items-center gap-2">
<div class="taff-chat-avatar">
<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-on-primary);">
<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>
<div>
<div class="text-sm font-semibold" style="color: var(--nav-text);">Guide AEP</div>
<div class="text-xs" style="color: var(--nav-text-muted);">Je t'aide à choisir ta plateforme</div>
</div>
</div>
<button @click="chatOpen = false" class="taff-chat-close" aria-label="Fermer">
<svg width="14" height="14" 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>
</div>
<!-- Messages -->
<div class="taff-chat-messages" ref="chatMessagesEl">
<!-- Message d'accueil -->
<div class="taff-msg taff-msg--bot">
<p>Dis-moi ta situation : ton secteur, ta zone, ce qui te bloque. Je t'oriente parmi les {{ allPlateformes.length }} plateformes de l'annuaire.</p>
<p class="text-xs mt-1.5 opacity-60">Ex : "Je suis en rénovation à Lyon, je cherche des leads sans commission."</p>
</div>
<!-- Messages de la conversation -->
<template v-for="(msg, i) in chatMessages" :key="i">
<div :class="['taff-msg', msg.role === 'user' ? 'taff-msg--user' : 'taff-msg--bot']">
<div v-if="msg.role === 'bot'" class="md-content" v-html="renderMd(msg.content)" />
<span v-else>{{ msg.content }}</span>
</div>
<!-- Plateformes recommandées -->
<div v-if="msg.role === 'bot' && msg.recommandations?.length" class="taff-chat-recos">
<button
v-for="r in msg.recommandations"
:key="r.id"
class="taff-reco-chip"
@click="openModalById(r.id); chatOpen = false"
>
{{ r.nom }}
<span class="taff-reco-arrow">→</span>
</button>
</div>
</template>
<!-- Loader -->
<div v-if="chatLoading" class="taff-msg taff-msg--bot">
<span class="taff-typing"><span/><span/><span/></span>
</div>
</div>
<!-- Input -->
<div class="taff-chat-input-wrap">
<textarea
v-model="chatInput"
class="taff-chat-input"
placeholder="Décris ta situation..."
rows="2"
@keydown.enter.exact.prevent="sendChat"
/>
<button
class="taff-chat-send"
:disabled="chatLoading || !chatInput.trim()"
@click="sendChat"
aria-label="Envoyer"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<p class="taff-chat-footer-note">Propulsé par Mistral · 20 questions/jour</p>
</div>
</Transition>
</Teleport>
<!-- ── Note juridique ────────────────────────────────────────── -->
<div class="taff-disclaimer">
<p>
Évaluations basées sur des données publiques (CGV, Trustpilot, presse spécialisée) collectées en mai 2026.
AEP est un méta-annuaire critique, pas un opérateur. Les fiches « À éviter ❌ » sont validées manuellement avant publication.
</p>
</div>
<!-- ── Modal ─────────────────────────────────────────────────── -->
<Teleport to="body">
<Transition name="taff-backdrop">
<div
v-if="modalPlateforme"
class="fixed inset-0 z-[10000]"
style="background: rgba(26,34,56,0.55);"
@click="closeModal"
aria-hidden="true"
/>
</Transition>
<Transition name="taff-modal">
<div
v-if="modalPlateforme"
class="fixed z-[10001] left-1/2 -translate-x-1/2 flex flex-col"
style="width: min(760px, 92vw); top: 72px; max-height: calc(100vh - 88px); background: var(--nav-bg); border-radius: 16px; box-shadow: 0 16px 64px rgba(26,34,56,0.28); overflow: hidden;"
role="dialog"
aria-modal="true"
:aria-label="modalPlateforme.nom"
tabindex="-1"
@keydown.esc="closeModal"
>
<!-- Header -->
<div class="flex items-center justify-between px-5 py-3 shrink-0" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm font-semibold shrink-0"
:style="`background: ${modalTagConfig.bg}; color: ${modalTagConfig.text};`"
>{{ modalTagConfig.emoji }} {{ modalTagConfig.label }}</span>
<span class="font-semibold text-base truncate" style="color: var(--nav-text);">{{ modalPlateforme.nom }}</span>
</div>
<div class="flex items-center gap-2 shrink-0 ml-3">
<a
:href="modalPlateforme.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text);"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Visiter
</a>
<button
@click="closeModal"
class="w-8 h-8 rounded-lg flex items-center justify-center hover:opacity-70 transition-opacity"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
aria-label="Fermer"
>
<svg width="14" height="14" 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>
</div>
</div>
<!-- Body -->
<div class="overflow-y-auto flex-1 px-5 py-5 space-y-5">
<!-- Scoring axes -->
<div>
<div class="modal-label">Évaluation AEP — 5 axes</div>
<div class="modal-axes">
<div
v-for="axe in AXES"
:key="axe.id"
v-show="modalPlateforme.scoring[axe.id] !== null"
class="modal-axe"
:style="`background: ${axeScoreBg(modalPlateforme.scoring[axe.id] as string)};`"
>
<span class="text-xl leading-none">{{ axe.icon }}</span>
<div class="flex flex-col">
<span class="text-xs font-bold uppercase tracking-wider" style="color: var(--nav-text-muted);">{{ axe.label }}</span>
<span class="text-lg leading-none" :style="`color: ${axeScoreText(modalPlateforme.scoring[axe.id] as string)};`">
{{ modalPlateforme.scoring[axe.id] }}
</span>
</div>
</div>
</div>
<p class="mt-3 text-sm leading-relaxed italic rounded-lg px-3 py-2.5" style="color: var(--nav-text-muted); background: var(--nav-bg-alt);">
{{ modalPlateforme.scoring.justification_tag }}
</p>
</div>
<!-- Description -->
<div>
<div class="modal-label">Fiche détaillée</div>
<div class="space-y-3">
<div v-for="section in parsedDescription" :key="section.title">
<h5 class="text-sm font-bold mb-1" style="color: var(--nav-text);">{{ section.title }}</h5>
<p class="text-sm leading-relaxed" style="color: var(--nav-text-muted);">{{ section.body }}</p>
</div>
</div>
</div>
<!-- Infos pratiques -->
<div>
<div class="modal-label">Infos pratiques</div>
<div class="modal-meta-grid">
<div class="modal-meta-item">
<span class="modal-meta-key">Type</span>
<span class="modal-meta-val">{{ modalPlateforme.type === 'b2c-mise-en-relation' ? 'Plateforme B2C' : 'Appels d\'offres publics' }}</span>
</div>
<div class="modal-meta-item">
<span class="modal-meta-key">Coût d'entrée</span>
<span class="modal-meta-val">{{ coutLabel(modalPlateforme.cout_entree) }}</span>
</div>
<div class="modal-meta-item">
<span class="modal-meta-key">Zone</span>
<span class="modal-meta-val">France entière</span>
</div>
<div class="modal-meta-item">
<span class="modal-meta-key">Secteurs</span>
<span class="modal-meta-val">{{ modalPlateforme.secteurs_servis.map(s => SECTEUR_LABELS[s] ?? s).join(', ') }}</span>
</div>
</div>
</div>
<!-- Footer meta -->
<div class="flex items-center gap-2 text-xs flex-wrap pb-1" style="color: var(--nav-text-muted);">
<span>Fiche créée le {{ modalPlateforme.date_creation_fiche }}</span>
<span>·</span>
<span>{{ modalPlateforme.source_donnees.length }} source{{ modalPlateforme.source_donnees.length > 1 ? 's' : '' }}</span>
<span v-if="modalPlateforme.flag_validation_jules" class="font-semibold" style="color: #7a5f2a;">
· ⚠️ En attente de validation avant publication
</span>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { PlateformeTaff } from '~/types/plateforme-taff'
useHead({
title: 'Trouver du taf en archi — AEP',
meta: [
{ name: 'description', content: "Annuaire critique des plateformes B2C archiparticulier. Évaluations éthiques sur 5 axes : rémunération, transparence, pratiques pro, écologie, matching." }
]
})
const { data } = await useAsyncData('plateformes-taff', () =>
$fetch<{ meta: any; plateformes: PlateformeTaff[] }>('/data/plateformes-taff.json')
)
const allPlateformes = computed(() => data.value?.plateformes ?? [])
const stats = computed(() => data.value?.meta?.repartition ?? { recommande: 0, sous_reserve: 0, a_eviter: 0 })
const b2cCount = computed(() => allPlateformes.value.filter(p => p.type === 'b2c-mise-en-relation').length)
const aoCount = computed(() => allPlateformes.value.filter(p => p.type === 'appel-offre-public').length)
// Filtres
const activeTab = ref<'b2c' | 'ao'>('b2c')
const filterTag = ref('')
const filterSecteur = ref('')
const search = ref('')
const hasFilters = computed(() => !!(filterTag.value || filterSecteur.value || search.value))
const activeFilterCount = computed(() => {
let n = 0
if (filterTag.value) n++
if (filterSecteur.value) n++
if (search.value) n++
return n
})
// Filtres ouverts par défaut sur desktop, repliés sur mobile (init côté client)
const filtersExpanded = ref(true)
onMounted(() => {
if (typeof window !== 'undefined' && window.innerWidth < 768) {
filtersExpanded.value = false
}
})
function resetFilters() {
filterTag.value = ''
filterSecteur.value = ''
search.value = ''
}
const filtered = computed(() => {
let list = allPlateformes.value.filter(p =>
activeTab.value === 'b2c'
? p.type === 'b2c-mise-en-relation'
: p.type === 'appel-offre-public'
)
if (filterTag.value)
list = list.filter(p => p.scoring.tag_global === filterTag.value)
if (filterSecteur.value)
list = list.filter(p => (p.secteurs_servis as string[]).includes(filterSecteur.value))
if (search.value) {
const q = search.value.toLowerCase()
list = list.filter(p =>
p.nom.toLowerCase().includes(q) ||
p.description_courte.toLowerCase().includes(q)
)
}
const ORDER: Record<string, number> = { 'recommande': 0, 'sous-reserve': 1, 'a-eviter': 2 }
return [...list].sort((a, b) => (ORDER[a.scoring.tag_global] ?? 9) - (ORDER[b.scoring.tag_global] ?? 9))
})
// Options filtres
const TAG_OPTIONS = [
{ value: 'recommande', emoji: '✅', label: 'Recommandé', bg: 'rgba(90,122,74,0.12)', text: '#3d5534', accent: '#5a7a4a' },
{ value: 'sous-reserve', emoji: '⚠️', label: 'Sous réserve', bg: 'rgba(196,164,114,0.15)', text: '#7a5f2a', accent: '#c4a472' },
{ value: 'a-eviter', emoji: '❌', label: 'À éviter', bg: 'rgba(168,93,62,0.12)', text: '#7a3322', accent: '#a85d3e' },
]
const SECTEUR_OPTIONS = [
{ value: 'renovation', label: 'Rénovation' },
{ value: 'construction-neuve', label: 'Neuf' },
{ value: 'architecture-interieure', label: 'Archi intérieure' },
{ value: 'mar-conseil', label: 'MAR / Conseil' },
{ value: 'urbanisme', label: 'Urbanisme' },
{ value: 'paysage', label: 'Paysage' },
{ value: 'transversal', label: 'Transversal' },
]
const SECTEUR_LABELS: Record<string, string> = {
'renovation': 'Rénovation', 'construction-neuve': 'Neuf',
'architecture-interieure': 'Archi intérieure', 'urbanisme': 'Urbanisme',
'paysage': 'Paysage', 'mar-conseil': 'MAR / Conseil', 'transversal': 'Transversal',
}
const COUT_LABELS: Record<string, string> = {
'gratuit': 'Gratuit', 'freemium': 'Freemium',
'abonnement': 'Abonnement', 'lead-paye': 'Lead payant', 'commission': 'Commission',
}
function coutLabel(c: string) { return COUT_LABELS[c] ?? c }
// Axes
const AXES = [
{ id: 'remuneration' as const, icon: '🪙', label: 'Rémunération' },
{ id: 'transparence' as const, icon: '🔍', label: 'Transparence' },
{ id: 'pratiques' as const, icon: '⚖️', label: 'Pratiques pro' },
{ id: 'ecologie' as const, icon: '🌿', label: 'Écologie' },
{ id: 'matching' as const, icon: '🎯', label: 'Matching' },
]
function axeScoreBg(score: string) {
if (score === '✅') return 'rgba(90,122,74,0.1)'
if (score === '⚠️') return 'rgba(196,164,114,0.15)'
if (score === '❌') return 'rgba(168,93,62,0.1)'
return 'var(--nav-bg-alt)'
}
function axeScoreText(score: string) {
if (score === '✅') return '#3d5534'
if (score === '⚠️') return '#7a5f2a'
if (score === '❌') return '#7a3322'
return 'var(--nav-text-muted)'
}
// Modal
const modalPlateforme = ref<PlateformeTaff | null>(null)
function openModal(p: PlateformeTaff) { modalPlateforme.value = p }
function closeModal() { modalPlateforme.value = null }
function openModalById(id: string) {
const p = allPlateformes.value.find(p => p.id === id)
if (p) modalPlateforme.value = p
}
// Chatbot
interface ChatMessage {
role: 'user' | 'bot'
content: string
recommandations?: { id: string; nom: string; raison: string }[]
}
const { render: renderMd } = useMarkdown()
const chatOpen = ref(false)
const chatInput = ref('')
const chatLoading = ref(false)
const chatMessages = ref<ChatMessage[]>([])
const chatMessagesEl = ref<HTMLElement | null>(null)
async function sendChat() {
const q = chatInput.value.trim()
if (!q || chatLoading.value) return
chatMessages.value.push({ role: 'user', content: q })
chatInput.value = ''
chatLoading.value = true
await nextTick()
chatMessagesEl.value?.scrollTo({ top: chatMessagesEl.value.scrollHeight, behavior: 'smooth' })
try {
const res = await $fetch<{ reponse_texte: string; plateformes_recommandees: { id: string; nom: string; raison: string }[] }>(
'/api/chatbot-taff',
{ method: 'POST', body: { question: q } }
)
chatMessages.value.push({
role: 'bot',
content: res.reponse_texte,
recommandations: res.plateformes_recommandees ?? [],
})
} catch (e: any) {
chatMessages.value.push({ role: 'bot', content: e?.data?.statusMessage ?? 'Erreur — réessaie dans un instant.' })
} finally {
chatLoading.value = false
await nextTick()
chatMessagesEl.value?.scrollTo({ top: chatMessagesEl.value.scrollHeight, behavior: 'smooth' })
}
}
const TAG_CONFIG: Record<string, { emoji: string; label: string; bg: string; text: string }> = {
'recommande': { emoji: '✅', label: 'Recommandé AEP', bg: 'rgba(90,122,74,0.12)', text: '#3d5534' },
'sous-reserve': { emoji: '⚠️', label: 'Sous réserve', bg: 'rgba(196,164,114,0.15)', text: '#7a5f2a' },
'a-eviter': { emoji: '❌', label: 'À éviter', bg: 'rgba(168,93,62,0.12)', text: '#7a3322' },
}
const modalTagConfig = computed(() =>
modalPlateforme.value
? (TAG_CONFIG[modalPlateforme.value.scoring.tag_global] ?? TAG_CONFIG['sous-reserve'])
: TAG_CONFIG['sous-reserve']
)
// Parse description (format "## Titre\nContenu\n\n## Titre2\nContenu2")
const parsedDescription = computed(() => {
if (!modalPlateforme.value) return []
const raw = modalPlateforme.value.description
const sections: { title: string; body: string }[] = []
const parts = raw.split(/\n\n## /)
parts.forEach((part, i) => {
const text = i === 0 ? part.replace(/^## /, '') : part
const nl = text.indexOf('\n')
if (nl < 0) return
sections.push({ title: text.slice(0, nl).trim(), body: text.slice(nl + 1).trim() })
})
return sections
})
</script>
<style scoped>
.taff-page { max-width: 1280px; margin: 0 auto; padding-bottom: 3rem; }
.taff-header { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--nav-bg-alt); text-align: center; }
.taff-header-inner { max-width: 680px; margin: 0 auto; }
.taff-title { font-size: 1.875rem; font-weight: 800; color: var(--nav-text); margin-bottom: 0.5rem; letter-spacing: -0.02em; }
.taff-subtitle { font-size: 0.9375rem; color: var(--nav-text-muted); line-height: 1.6; margin-bottom: 0.625rem; }
.taff-cible { font-size: 0.875rem; color: var(--nav-text-muted); line-height: 1.55; margin-bottom: 1rem; font-style: italic; }
.taff-cible strong { color: var(--nav-text); font-style: normal; }
.taff-stats { display: flex; gap: 1.25rem; flex-wrap: wrap; justify-content: center; margin-top: 1rem; }
.taff-stat { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; font-weight: 600; }
/* Pédagogie repliable */
.taff-pedago {
background: var(--nav-bg);
border: 1px solid var(--nav-bg-alt);
border-radius: 12px;
margin: 1rem 0 0.75rem;
overflow: hidden;
}
.taff-pedago-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--nav-text);
cursor: pointer;
list-style: none;
}
.taff-pedago-summary::-webkit-details-marker { display: none; }
.taff-pedago-chevron {
color: var(--nav-text-muted);
transition: transform 0.2s ease;
}
.taff-pedago[open] .taff-pedago-chevron { transform: rotate(180deg); }
.taff-pedago-body {
padding: 0 1rem 1rem;
border-top: 1px solid var(--nav-bg-alt);
}
.taff-pedago-block { margin-top: 0.875rem; }
.taff-pedago-block h3 {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--nav-text-muted);
font-weight: 700;
margin: 0 0 0.5rem;
}
.taff-pedago-block ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.taff-pedago-block li {
font-size: 0.85rem;
line-height: 1.55;
color: var(--nav-text);
}
.taff-pedago-block li strong { font-weight: 700; }
.taff-pedago-tag {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
font-size: 0.78rem;
font-weight: 600;
margin-right: 0.25rem;
}
.taff-pedago-axes {
font-size: 0.85rem;
line-height: 1.65;
color: var(--nav-text);
margin: 0;
}
.taff-pedago-axes strong { font-weight: 700; }
@media (max-width: 600px) {
.taff-pedago-body { padding: 0 0.85rem 0.85rem; }
.taff-pedago-block li, .taff-pedago-axes { font-size: 0.82rem; }
}
.taff-stat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.taff-filters-bar { position: sticky; top: 0; z-index: 100; background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt); padding: 0.75rem 1.5rem; box-shadow: 0 2px 8px rgba(26,34,56,0.06); }
.taff-filters-inner { display: flex; align-items: center; gap: 0.625rem; flex-wrap: wrap; }
.taff-filters-collapsible { display: contents; }
/* Toggle filtres : visible uniquement sur mobile */
.taff-filters-toggle { display: none; }
@media (max-width: 767px) {
.taff-filters-bar { padding: 0.5rem 0.875rem; }
.taff-filters-inner { gap: 0.4rem; }
.taff-tabs { width: 100%; justify-content: space-between; }
.taff-tabs .taff-tab { flex: 1; justify-content: center; }
.taff-filters-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.375rem 0.75rem;
background: var(--nav-bg);
border: 1px solid var(--nav-bg-alt);
border-left: none;
color: var(--nav-text-muted);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
}
.taff-filters-toggle-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9999px;
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
font-size: 0.7rem;
font-weight: 700;
}
.taff-filters-collapsible {
display: flex;
width: 100%;
flex-direction: column;
gap: 0.5rem;
overflow: hidden;
max-height: 1000px;
transition: max-height 0.3s ease, opacity 0.2s ease, margin-top 0.2s ease;
margin-top: 0.4rem;
opacity: 1;
}
.taff-filters-bar--collapsed .taff-filters-collapsible {
max-height: 0;
opacity: 0;
margin-top: 0;
pointer-events: none;
}
}
.taff-tabs { display: flex; border-radius: 8px; overflow: hidden; border: 1px solid var(--nav-bg-alt); flex-shrink: 0; }
.taff-tab { display: flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.875rem; font-size: 0.8125rem; font-weight: 500; color: var(--nav-text-muted); background: var(--nav-bg); border: none; cursor: pointer; transition: background 0.15s; }
.taff-tab:first-child { border-right: 1px solid var(--nav-bg-alt); }
.taff-tab--active { background: var(--nav-primary-solid); color: var(--nav-text-on-primary); }
.taff-tab-count { font-size: 0.6875rem; opacity: 0.7; font-weight: 700; }
.taff-filter-group { display: flex; gap: 0.375rem; flex-wrap: wrap; }
.taff-filter-btn { padding: 0.3125rem 0.75rem; border-radius: 9999px; font-size: 0.8125rem; font-weight: 500; border: 1px solid var(--nav-bg-alt); background: var(--nav-bg); color: var(--nav-text-muted); cursor: pointer; transition: all 0.15s; white-space: nowrap; }
.taff-filter-btn:hover { background: var(--nav-bg-alt); }
.taff-filter-btn--active { font-weight: 600; }
.taff-search { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; border-radius: 8px; border: 1px solid var(--nav-bg-alt); background: var(--nav-bg); flex: 1; min-width: 160px; max-width: 240px; }
.taff-search-input { flex: 1; background: transparent; border: none; outline: none; font-size: 0.8125rem; color: var(--nav-text); min-width: 0; }
.taff-search-input::placeholder { color: var(--nav-text-muted); }
.taff-search-clear { color: var(--nav-text-muted); background: none; border: none; cursor: pointer; padding: 0; display: flex; }
.taff-grid-wrap { padding: 1.5rem; }
.taff-grid { display: flex; flex-direction: column; gap: 0.75rem; max-width: 720px; margin: 0 auto; }
.taff-empty { text-align: center; padding: 3rem; }
.taff-reset-btn { margin-top: 0.75rem; padding: 0.5rem 1.25rem; border-radius: 8px; background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; border: none; cursor: pointer; }
.taff-reset-btn:hover { opacity: 0.7; }
.taff-disclaimer { margin: 0 1.5rem; padding: 0.875rem 1.25rem; border-radius: 10px; font-size: 0.8125rem; line-height: 1.55; color: var(--nav-text-muted); background: var(--nav-bg-alt); }
/* Modal body helpers */
.modal-label { font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nav-text-muted); margin-bottom: 0.75rem; }
.modal-axes { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.modal-axe { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.875rem; border-radius: 8px; flex: 1 1 130px; min-width: 130px; }
.modal-meta-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; }
.modal-meta-item { display: flex; flex-direction: column; gap: 0.15rem; padding: 0.6rem 0.875rem; border-radius: 8px; background: var(--nav-bg-alt); }
.modal-meta-key { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; color: var(--nav-text-muted); }
.modal-meta-val { font-size: 0.875rem; font-weight: 500; color: var(--nav-text); }
/* Transitions modal */
.taff-backdrop-enter-active, .taff-backdrop-leave-active { transition: opacity 0.2s; }
.taff-backdrop-enter-from, .taff-backdrop-leave-to { opacity: 0; }
.taff-modal-enter-active, .taff-modal-leave-active { transition: opacity 0.2s, transform 0.2s; }
.taff-modal-enter-from, .taff-modal-leave-to { opacity: 0; transform: translateX(-50%) translateY(-12px); }
/* ── Chatbot FAB ──────────────────────────────────────────────────── */
.taff-fab {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9998;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.125rem;
border-radius: 9999px;
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
font-size: 0.875rem;
font-weight: 600;
border: none;
cursor: pointer;
box-shadow: 0 4px 20px rgba(26,34,56,0.3);
transition: transform 0.15s, box-shadow 0.15s;
}
.taff-fab:hover { transform: translateY(-2px); box-shadow: 0 8px 28px rgba(26,34,56,0.35); }
.taff-fab-label { white-space: nowrap; }
/* Panel chatbot */
.taff-chat-panel {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
width: min(380px, calc(100vw - 2rem));
max-height: calc(100vh - 4rem);
background: var(--nav-surface);
border-radius: 16px;
box-shadow: 0 8px 40px rgba(26,34,56,0.25);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--nav-bg-alt);
}
.taff-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--nav-bg-alt);
background: var(--nav-surface);
flex-shrink: 0;
}
.taff-chat-avatar {
width: 32px; height: 32px;
border-radius: 50%;
background: var(--nav-primary-solid);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.taff-chat-close {
width: 28px; height: 28px;
border-radius: 8px;
background: var(--nav-bg-alt);
color: var(--nav-text-muted);
border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: opacity 0.15s;
}
.taff-chat-close:hover { opacity: 0.7; }
.taff-chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.taff-msg {
padding: 0.625rem 0.875rem;
border-radius: 12px;
font-size: 0.875rem;
line-height: 1.55;
max-width: 92%;
}
.taff-msg--bot {
background: var(--nav-bg-alt);
color: var(--nav-text);
align-self: flex-start;
border-bottom-left-radius: 4px;
}
.taff-msg--user {
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
align-self: flex-end;
border-bottom-right-radius: 4px;
}
.taff-chat-recos {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-self: flex-start;
max-width: 92%;
}
.taff-reco-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem 0.75rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 600;
background: var(--nav-bg);
color: var(--nav-text);
border: 1px solid var(--nav-bg-alt);
cursor: pointer;
transition: background 0.15s;
}
.taff-reco-chip:hover { background: var(--nav-bg-alt); }
.taff-reco-arrow { opacity: 0.5; }
/* Typing indicator */
.taff-typing { display: inline-flex; gap: 4px; align-items: center; }
.taff-typing span {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--nav-text-muted);
animation: taff-bounce 1.2s infinite;
}
.taff-typing span:nth-child(2) { animation-delay: 0.2s; }
.taff-typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes taff-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-5px); opacity: 1; }
}
.taff-chat-input-wrap {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid var(--nav-bg-alt);
background: var(--nav-surface);
flex-shrink: 0;
}
.taff-chat-input {
flex: 1;
resize: none;
border: 1px solid var(--nav-bg-alt);
border-radius: 10px;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
background: var(--nav-bg);
color: var(--nav-text);
font-family: var(--nav-font);
outline: none;
line-height: 1.5;
}
.taff-chat-input::placeholder { color: var(--nav-text-muted); }
.taff-chat-input:focus { border-color: var(--nav-primary); }
.taff-chat-send {
width: 36px; height: 36px;
border-radius: 10px;
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
transition: opacity 0.15s;
}
.taff-chat-send:disabled { opacity: 0.4; cursor: not-allowed; }
.taff-chat-send:not(:disabled):hover { opacity: 0.85; }
.taff-chat-footer-note {
text-align: center;
font-size: 0.6875rem;
color: var(--nav-text-muted);
padding: 0.375rem;
background: var(--nav-surface);
flex-shrink: 0;
}
/* Transition panel chatbot */
.taff-chat-enter-active, .taff-chat-leave-active { transition: opacity 0.2s, transform 0.2s; }
.taff-chat-enter-from, .taff-chat-leave-to { opacity: 0; transform: translateY(12px) scale(0.97); }
</style>