Files
nav-carte/pages/trouver-du-taf.vue
Jules Neny 19ff17e236 feat(taff): layout colonne + modal positionné + chatbot flottant
- Grille : 3 colonnes → 1 colonne centrée 720px (respire, 16 fiches)
- Modal : top fixe 72px au lieu de top-1/2 (ne mord plus le header)
- Chatbot FAB : bouton fixe bas-droite + panel slide-in avec Mistral
- /api/chatbot-taff : endpoint dédié lisant plateformes-taff.json
- Cartes : layout restructuré tag/nom/axes/desc-3-lignes/footer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 00:57:58 +02:00

801 lines
34 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 archi particulier.
Évaluées sur 5 axes éthiques rémunération, transparence, pratiques pro, écologie, qualité du matching.
Cible : archi freelance indépendant en France.
</p>
<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">
<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()"
>
Plateformes B2C
<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>
</div>
<!-- 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>
</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']">
<p>{{ msg.content }}</p>
</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))
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 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); }
.taff-header-inner { max-width: 680px; }
.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: 1rem; }
.taff-stats { display: flex; gap: 1.25rem; flex-wrap: wrap; }
.taff-stat { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; font-weight: 600; }
.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-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>