Files
nav-carte/pages/trouver-du-taf.vue
Jules Neny 0378f2bd72 fix(taff): 3 corrections UI — modal z-index, axes flex, cards layout
- Modal z-index 1501→10001 (au-dessus du header 9999)
- Axes modal: grid→flex avec flex-basis 130px (plus de wrap PRATIQUES PRO)
- Cartes: layout restructuré — tag / nom / axes / desc 3 lignes / footer séparé

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

473 lines
23 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>
<!-- ── 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] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
style="width: min(760px, 92vw); max-height: 90vh; 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 }
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: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.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 */
.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)); }
</style>