diff --git a/pages/trouver-du-taf.vue b/pages/trouver-du-taf.vue
index 1c231b2..aee7e1a 100644
--- a/pages/trouver-du-taf.vue
+++ b/pages/trouver-du-taf.vue
@@ -135,6 +135,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dis-moi ta situation : ton secteur, ta zone, ce qui te bloque. Je t'oriente parmi les {{ allPlateformes.length }} plateformes de l'annuaire.
+
Ex : "Je suis en rénovation à Lyon, je cherche des leads sans commission."
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -157,8 +255,8 @@
(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 chatOpen = ref(false)
+const chatInput = ref('')
+const chatLoading = ref(false)
+const chatMessages = ref([])
+const chatMessagesEl = ref(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 = {
'recommande': { emoji: '✅', label: 'Recommandé AEP', bg: 'rgba(90,122,74,0.12)', text: '#3d5534' },
@@ -448,7 +590,7 @@ const parsedDescription = computed(() => {
.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: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
+.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; }
@@ -464,9 +606,195 @@ const parsedDescription = computed(() => {
.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 */
+/* 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: translate(-50%, calc(-50% + 12px)); }
+.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); }
diff --git a/server/api/chatbot-taff.post.ts b/server/api/chatbot-taff.post.ts
new file mode 100644
index 0000000..87a6e93
--- /dev/null
+++ b/server/api/chatbot-taff.post.ts
@@ -0,0 +1,143 @@
+/**
+ * POST /api/chatbot-taff
+ * Chatbot d'aiguillage — Carte 3 "Trouver du taf"
+ * Lit plateformes-taff.json, appelle Mistral Small, retourne recommandations.
+ */
+
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
+
+interface PlateformeMinimal {
+ id: string
+ nom: string
+ type: string
+ description_courte: string
+ scoring: {
+ remuneration: string | null
+ transparence: string | null
+ pratiques: string | null
+ ecologie: string | null
+ matching: string | null
+ tag_global: string
+ justification_tag: string
+ }
+ secteurs_servis: string[]
+ cout_entree: string
+}
+
+const SYSTEM_PROMPT = `Tu es un conseiller expert au service des architectes indépendants français. Tu connais toutes les plateformes de mise en relation architecte↔particulier et les agrégateurs d'appels d'offres publics référencés par AEP (Architecture d'Écologie Politique).
+
+Ton rôle : aider l'architecte à choisir LA ou LES plateformes adaptées à sa situation, en t'appuyant exclusivement sur les données ci-dessous.
+
+RÈGLES :
+1. Ne recommande QUE des plateformes présentes dans le contexte ci-dessous.
+2. Sois direct et opinionné — c'est ça la valeur d'AEP.
+3. Si une plateforme est ❌ "À éviter", signale-le clairement.
+4. Réponse max 250 mots, en français, ton conseiller pair.
+5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
+
+FORMAT :
+{
+ "reponse_texte": "Ta réponse en prose, max 250 mots",
+ "plateformes_recommandees": [
+ { "id": "slug-kebab", "nom": "Nom plateforme", "raison": "Pourquoi cette plateforme en 1 phrase" }
+ ]
+}
+
+PLATEFORMES DISPONIBLES :
+{{PLATEFORMES_JSON}}`
+
+export default defineEventHandler(async (event) => {
+ const config = useRuntimeConfig()
+
+ const ip =
+ getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
+ event.node.req.socket?.remoteAddress ||
+ '0.0.0.0'
+
+ const allowed = checkRateLimitJson(ip, 'chatbot-taff', 20)
+ if (!allowed) {
+ throw createError({ statusCode: 429, statusMessage: 'Limite de 20 questions par jour atteinte.' })
+ }
+
+ const body = await readBody(event)
+ const question: string = (body?.question ?? '').trim()
+ if (!question || question.length < 5) {
+ throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
+ }
+
+ // Lire le JSON statique des plateformes
+ let plateformes: PlateformeMinimal[] = []
+ try {
+ const jsonPath = resolve(process.cwd(), 'public/data/plateformes-taff.json')
+ const raw = JSON.parse(readFileSync(jsonPath, 'utf8'))
+ plateformes = (raw.plateformes ?? []).map((p: any) => ({
+ id: p.id,
+ nom: p.nom,
+ type: p.type,
+ description_courte: p.description_courte,
+ scoring: p.scoring,
+ secteurs_servis: p.secteurs_servis,
+ cout_entree: p.cout_entree,
+ }))
+ } catch (e) {
+ throw createError({ statusCode: 500, statusMessage: 'Données plateformes introuvables.' })
+ }
+
+ const context = plateformes.map(p => ({
+ id: p.id,
+ nom: p.nom,
+ type: p.type === 'b2c-mise-en-relation' ? 'B2C' : 'Appels offres publics',
+ tag: p.scoring.tag_global,
+ resume: p.description_courte,
+ secteurs: p.secteurs_servis.join(', '),
+ cout: p.cout_entree,
+ justification: p.scoring.justification_tag,
+ }))
+
+ const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
+
+ const mistralApiKey = config.mistralApiKey as string
+ if (!mistralApiKey) {
+ throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
+ }
+
+ let mistralRaw: string
+ try {
+ const res = await $fetch<{ choices: { message: { content: string } }[] }>(
+ '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: 700,
+ response_format: { type: 'json_object' },
+ messages: [
+ { role: 'system', content: systemPrompt },
+ { role: 'user', content: question },
+ ],
+ }),
+ }
+ )
+ mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
+ } catch {
+ throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
+ }
+
+ try {
+ const parsed = JSON.parse(mistralRaw)
+ return {
+ reponse_texte: parsed.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
+ plateformes_recommandees: (parsed.plateformes_recommandees ?? []).map((r: any) => ({
+ id: r.id,
+ nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id,
+ raison: r.raison ?? '',
+ })),
+ }
+ } catch {
+ return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
+ }
+})