M1 - Chips a11y : converti les <span> chips mobile (criteres, types, echelle, fonctions) en <button type=button> avec aria-pressed pour support clavier et lecteurs d'ecran (sidebar desktop deja en boutons). M2 - Effacer les filtres ne vide pas la search : resetFilters() reset maintenant aussi mobileSearch dans pratiques-regeneratives.vue et index.vue. M3 - FAB Soutenir overlap chip Agence : repositionne le FAB Soutenir en stack vertical avec le FAB Chatbot (right: 16px, bottom: 84px) au lieu de left: 16px, bottom: 68px. Evite l'overlap avec les chips de la bottom-sheet sur viewport intermediaire. L1 - /fiche/[id] introuvable pour pratiques : ajoute un fallback dans pages/fiche/[id].vue qui detecte si l'id correspond a une pratique regenerative et redirige vers /pratique/[id] (navigateTo replace). L2 - Label retour incorrect sur /proposer-pratique : harmonise en 'Retour aux pratiques regeneratives'. L3 - Map ne fitBounds pas apres filtre : EuropeMap et NavMap appellent maintenant fitBounds(bounds, padding 40px, maxZoom 10) quand la liste filtree contient 1 a 15 markers. Saute le tout premier rendu pour preserver la vue initiale.
565 lines
23 KiB
Vue
565 lines
23 KiB
Vue
<template>
|
|
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
|
|
|
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (≥ 1024px) -->
|
|
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
|
|
<PratiqueSidebar
|
|
:search="search"
|
|
:criteres="criteres"
|
|
:typesEntite="typesEntite"
|
|
:critereCount="critereCount"
|
|
:typeCount="typeCount"
|
|
:resultCount="filtered.length"
|
|
:pratiques="filtered"
|
|
:selectedId="selectedId"
|
|
:hasActiveFilters="hasActiveFilters"
|
|
:pending="pending"
|
|
@update:search="onSearch"
|
|
@update:criteres="onCriteres"
|
|
@update:typesEntite="onTypesEntite"
|
|
@select-pratique="onSelectPratique"
|
|
@hover-pratique="onHoverPratique"
|
|
@reset-filters="resetFilters"
|
|
/>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
|
<main class="flex-1 flex flex-col overflow-hidden relative">
|
|
|
|
<!-- ── VUE DESKTOP : Onglets Europe / Outre-mer + carte pleine hauteur ── -->
|
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
|
|
|
<!-- Onglets Europe / Outre-mer (desktop) -->
|
|
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
|
<button
|
|
type="button"
|
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
:style="desktopMapView === 'europe'
|
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
@click="desktopMapView = 'europe'"
|
|
>
|
|
Europe
|
|
<span class="ml-1 text-xs opacity-70">({{ europeOrgs.length }})</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
:style="desktopMapView === 'outremer'
|
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
@click="desktopMapView = 'outremer'"
|
|
>
|
|
Outre-mer
|
|
<span class="ml-1 text-xs opacity-70">({{ outremerOrgs.length }})</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Carte Europe (pleine hauteur) -->
|
|
<div v-show="desktopMapView === 'europe'" class="flex flex-col flex-1 overflow-hidden">
|
|
<div class="relative flex-1" style="min-height: 200px;">
|
|
<ClientOnly>
|
|
<EuropeMap
|
|
ref="europeMapRef"
|
|
:orgs="europeOrgs"
|
|
:selectedId="selectedId"
|
|
@select-org="onSelectPratique"
|
|
/>
|
|
<template #fallback>
|
|
<div
|
|
class="w-full h-full flex items-center justify-center"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
>
|
|
Chargement de la carte…
|
|
</div>
|
|
</template>
|
|
</ClientOnly>
|
|
</div>
|
|
<ChatbotPlaceholder
|
|
endpoint="/api/chatbot-pratiques"
|
|
title="Chatbot Pratiques régé"
|
|
placeholder="Pose une question sur les pratiques régénératives…"
|
|
ficheBasePath="/pratique"
|
|
@highlightOrgs="onHighlightOrgs"
|
|
>
|
|
<template #onboarding>
|
|
<p>Ce chatbot interroge la base des pratiques régénératives
|
|
(Mistral FR, serveur européen souverain, zéro rétention).</p>
|
|
<p>Pour t'aider à trouver les pratiques pertinentes,
|
|
formule ta requête ainsi :</p>
|
|
<ul>
|
|
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
|
|
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
|
|
<li>• Lieu : [pays ou région]</li>
|
|
</ul>
|
|
<p class="example">Exemple : "Je cherche une coopérative qui travaille
|
|
le réemploi de matériaux en Belgique."</p>
|
|
</template>
|
|
</ChatbotPlaceholder>
|
|
</div>
|
|
|
|
<!-- Carte Outre-mer (pleine hauteur, scroll) -->
|
|
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
|
|
<ClientOnly>
|
|
<OutremerMapPratiques
|
|
:orgs="outremerOrgs"
|
|
:selectedId="selectedId"
|
|
@select-org="onSelectPratique"
|
|
/>
|
|
<template #fallback>
|
|
<div
|
|
class="flex items-center justify-center h-48 text-sm"
|
|
style="color: var(--nav-text-muted);"
|
|
>
|
|
Chargement…
|
|
</div>
|
|
</template>
|
|
</ClientOnly>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── VUE MOBILE : Onglets Europe/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
|
|
|
<!-- Onglets Europe / Outre-mer -->
|
|
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
|
<button
|
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
:style="mobileMapView === 'europe'
|
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
@click="mobileMapView = 'europe'"
|
|
>Europe</button>
|
|
<button
|
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
:style="mobileMapView === 'outremer'
|
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
@click="mobileMapView = 'outremer'"
|
|
>Outre-mer</button>
|
|
</div>
|
|
|
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
|
|
|
<!-- Carte Europe -->
|
|
<div v-show="mobileMapView === 'europe'" class="absolute inset-0">
|
|
<ClientOnly>
|
|
<EuropeMap
|
|
ref="europeMapMobileRef"
|
|
:orgs="europeOrgs"
|
|
:selectedId="selectedId"
|
|
@select-org="onSelectPratiqueMobile"
|
|
/>
|
|
<template #fallback>
|
|
<div
|
|
class="w-full h-full flex items-center justify-center"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
>
|
|
Chargement de la carte…
|
|
</div>
|
|
</template>
|
|
</ClientOnly>
|
|
</div>
|
|
|
|
<!-- Carte Outre-mer -->
|
|
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
|
<ClientOnly>
|
|
<OutremerMapPratiques
|
|
:orgs="outremerOrgs"
|
|
:selectedId="selectedId"
|
|
@select-org="onSelectPratiqueMobile"
|
|
/>
|
|
<template #fallback>
|
|
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
|
Chargement…
|
|
</div>
|
|
</template>
|
|
</ClientOnly>
|
|
</div>
|
|
|
|
<!-- Bottom sheet swipable (Europe et Outre-mer) -->
|
|
<ClientOnly>
|
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
|
<!-- Barre recherche -->
|
|
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
|
<label class="mobile-search-label" aria-label="Rechercher une pratique">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
|
<circle cx="11" cy="11" r="8"/>
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
<input
|
|
v-model="mobileSearch"
|
|
type="search"
|
|
placeholder="Rechercher…"
|
|
class="mobile-search-input"
|
|
autocomplete="off"
|
|
@input="onSearch(mobileSearch)"
|
|
/>
|
|
<button
|
|
v-if="mobileSearch"
|
|
type="button"
|
|
class="mobile-search-clear"
|
|
aria-label="Effacer"
|
|
@click.stop="mobileSearch = ''; onSearch('')"
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</label>
|
|
|
|
<!-- Filtres CRITÈRES — chips -->
|
|
<div class="mt-2">
|
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">CRITÈRES</span>
|
|
<div class="flex flex-wrap gap-1">
|
|
<button
|
|
v-for="c in CRITERES"
|
|
:key="c.id"
|
|
type="button"
|
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
|
:style="criteres.includes(c.id)
|
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
|
:aria-pressed="criteres.includes(c.id)"
|
|
@click="toggleCritere(c.id)"
|
|
>{{ c.label }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filtres TYPE — chips -->
|
|
<div class="mt-2">
|
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">TYPE</span>
|
|
<div class="flex flex-wrap gap-1">
|
|
<button
|
|
v-for="t in TYPES_ENTITE"
|
|
:key="t"
|
|
type="button"
|
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
|
:style="typesEntite.includes(t)
|
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
|
:aria-pressed="typesEntite.includes(t)"
|
|
@click="toggleType(t)"
|
|
>{{ TYPES_ENTITE_LABELS[t] ?? t }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
v-if="hasActiveFilters"
|
|
@click="resetFilters"
|
|
class="mt-2 text-xs"
|
|
style="color: var(--nav-text-muted); text-decoration: underline;"
|
|
>Effacer les filtres</button>
|
|
</div>
|
|
|
|
<!-- Compteur + Liste fiches -->
|
|
<div class="px-3 py-2">
|
|
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
|
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
|
|
</div>
|
|
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
|
Chargement des fiches…
|
|
</div>
|
|
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
|
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
|
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
|
Effacer les filtres
|
|
</button>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div
|
|
v-for="pratique in filtered"
|
|
:key="pratique.id"
|
|
class="block rounded-lg p-3 transition-all cursor-pointer"
|
|
:style="selectedId === pratique.id
|
|
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
|
|
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
|
@click="onSelectPratiqueMobile(pratique.id)"
|
|
>
|
|
<div class="flex items-start justify-between gap-2">
|
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ pratique.nom }}</span>
|
|
<span
|
|
v-if="pratique.pays"
|
|
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
>{{ pratique.pays }}</span>
|
|
</div>
|
|
<div v-if="pratique.criteres?.length" class="mt-1 flex flex-wrap gap-1">
|
|
<span
|
|
v-for="cId in pratique.criteres.slice(0, 3)"
|
|
:key="cId"
|
|
class="px-1.5 py-0.5 rounded text-xs"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
>{{ CRITERES.find(c => c.id === cId)?.label }}</span>
|
|
</div>
|
|
<div v-if="pratique.ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
|
|
{{ pratique.ville }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</MobileSheet>
|
|
</ClientOnly>
|
|
</div>
|
|
|
|
</main>
|
|
|
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
|
<button
|
|
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
|
style="
|
|
height: 48px;
|
|
background: var(--nav-primary);
|
|
opacity: 0.92;
|
|
color: var(--nav-text-on-primary);
|
|
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
|
font-family: var(--nav-font);
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
"
|
|
aria-label="Ouvrir l'assistant Chatbot"
|
|
@click="chatbotOpen = true"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
</svg>
|
|
<span>Chatbot</span>
|
|
</button>
|
|
|
|
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
|
<ChatbotSheet
|
|
:modelValue="chatbotOpen"
|
|
endpoint="/api/chatbot-pratiques"
|
|
title="Chatbot Pratiques régé"
|
|
ficheBasePath="/pratique"
|
|
@update:modelValue="chatbotOpen = $event"
|
|
@highlightOrgs="onHighlightOrgs"
|
|
>
|
|
<template #onboarding>
|
|
<p>Ce chatbot interroge la base des pratiques régénératives
|
|
(Mistral FR, serveur européen souverain, zéro rétention).</p>
|
|
<p>Pour t'aider à trouver les pratiques pertinentes,
|
|
formule ta requête ainsi :</p>
|
|
<ul>
|
|
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
|
|
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
|
|
<li>• Lieu : [pays ou région]</li>
|
|
</ul>
|
|
<p class="example">Exemple : "Je cherche une coopérative qui travaille
|
|
le réemploi de matériaux en Belgique."</p>
|
|
</template>
|
|
</ChatbotSheet>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Pratique } from '~/types/pratique'
|
|
import { CRITERES, TYPES_ENTITE, TYPES_ENTITE_LABELS, EUROPE_CODES, OUTREMER_CODES } from '~/types/pratique'
|
|
|
|
// ── URL query params sync ─────────────────────────────────────────────────
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const search = ref<string>((route.query.q as string) ?? '')
|
|
const criteres = ref<number[]>(
|
|
route.query.criteres
|
|
? (route.query.criteres as string).split(',').map(Number).filter(Boolean)
|
|
: []
|
|
)
|
|
const typesEntite = ref<string[]>(
|
|
route.query.types
|
|
? (route.query.types as string).split(',').filter(Boolean)
|
|
: []
|
|
)
|
|
const pays = ref<string[]>(
|
|
route.query.pays
|
|
? (route.query.pays as string).split(',').filter(Boolean)
|
|
: []
|
|
)
|
|
|
|
const selectedId = ref<number | null>(null)
|
|
const mobileMapView = ref<'europe' | 'outremer'>('europe')
|
|
const desktopMapView = ref<'europe' | 'outremer'>('europe')
|
|
const chatbotOpen = ref(false)
|
|
|
|
// Surlignage temporaire (5 sec) suite a une reponse chatbot
|
|
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
|
const prevSelectedId = ref<number | null>(null)
|
|
|
|
function onHighlightOrgs(ids: (number | string)[]) {
|
|
if (!ids.length) return
|
|
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
|
|
if (isNaN(firstId)) return
|
|
|
|
prevSelectedId.value = selectedId.value
|
|
selectedId.value = firstId
|
|
|
|
if (highlightTimer) clearTimeout(highlightTimer)
|
|
highlightTimer = setTimeout(() => {
|
|
selectedId.value = prevSelectedId.value
|
|
prevSelectedId.value = null
|
|
highlightTimer = null
|
|
}, 5000)
|
|
}
|
|
|
|
// Refs vers les instances EuropeMap
|
|
const europeMapRef = ref<any>(null)
|
|
const europeMapMobileRef = ref<any>(null)
|
|
|
|
// Ref locale barre de recherche mobile
|
|
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
|
|
|
// Sync URL <-> état filtres
|
|
function syncUrl() {
|
|
const q: Record<string, string> = {}
|
|
if (search.value) q.q = search.value
|
|
if (criteres.value.length) q.criteres = criteres.value.join(',')
|
|
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
|
|
if (pays.value.length) q.pays = pays.value.join(',')
|
|
router.replace({ query: Object.keys(q).length ? q : undefined })
|
|
}
|
|
|
|
// Sauvegarde filtres pour bouton retour des fiches
|
|
function storeFiltersForBack() {
|
|
if (typeof window === 'undefined') return
|
|
const q: Record<string, string> = {}
|
|
if (search.value) q.q = search.value
|
|
if (criteres.value.length) q.criteres = criteres.value.join(',')
|
|
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
|
|
if (pays.value.length) q.pays = pays.value.join(',')
|
|
const qs = new URLSearchParams(q).toString()
|
|
sessionStorage.setItem('pratiques_back_filters', qs)
|
|
}
|
|
|
|
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
|
|
function onCriteres(v: number[]) { criteres.value = v; syncUrl(); storeFiltersForBack() }
|
|
function onTypesEntite(v: string[]) { typesEntite.value = v; syncUrl(); storeFiltersForBack() }
|
|
function onPays(v: string[]) { pays.value = v; syncUrl(); storeFiltersForBack() }
|
|
|
|
function onSelectPratique(id: number) {
|
|
selectedId.value = selectedId.value === id ? null : id
|
|
// Desktop : naviguer vers la fiche
|
|
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
|
storeFiltersForBack()
|
|
router.push(`/pratique/${id}`)
|
|
}
|
|
}
|
|
|
|
function onSelectPratiqueMobile(id: number) {
|
|
selectedId.value = id
|
|
storeFiltersForBack()
|
|
router.push(`/pratique/${id}`)
|
|
}
|
|
|
|
function onHoverPratique(id: number | null) {
|
|
if (id !== null) selectedId.value = id
|
|
}
|
|
|
|
const hasActiveFilters = computed(() =>
|
|
!!search.value || criteres.value.length > 0 || typesEntite.value.length > 0 || pays.value.length > 0
|
|
)
|
|
|
|
function resetFilters() {
|
|
search.value = ''
|
|
mobileSearch.value = ''
|
|
criteres.value = []
|
|
typesEntite.value = []
|
|
pays.value = []
|
|
router.replace({ query: undefined })
|
|
}
|
|
|
|
function toggleCritere(id: number) {
|
|
if (criteres.value.includes(id)) {
|
|
onCriteres(criteres.value.filter(v => v !== id))
|
|
} else {
|
|
onCriteres([...criteres.value, id])
|
|
}
|
|
}
|
|
|
|
function toggleType(t: string) {
|
|
if (typesEntite.value.includes(t)) {
|
|
onTypesEntite(typesEntite.value.filter(v => v !== t))
|
|
} else {
|
|
onTypesEntite([...typesEntite.value, t])
|
|
}
|
|
}
|
|
|
|
// Sync recherche depuis URL ?q=
|
|
watch(() => route.query.q, (v) => {
|
|
search.value = (v as string) ?? ''
|
|
})
|
|
|
|
// ── Données ───────────────────────────────────────────────────────────────
|
|
const { data, pending, error: fetchError } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques')
|
|
|
|
const pratiques = computed<Pratique[]>(() => data.value?.list ?? [])
|
|
|
|
// ── Filtrage côté client ──────────────────────────────────────────────────
|
|
const filtered = computed<Pratique[]>(() => {
|
|
let result = pratiques.value
|
|
|
|
if (search.value.trim()) {
|
|
const q = search.value.toLowerCase()
|
|
result = result.filter(
|
|
(o) =>
|
|
o.nom?.toLowerCase().includes(q) ||
|
|
o.ville?.toLowerCase().includes(q) ||
|
|
o.description?.toLowerCase().includes(q)
|
|
)
|
|
}
|
|
|
|
if (criteres.value.length) {
|
|
result = result.filter((o) =>
|
|
criteres.value.some((cId) => o.criteres?.includes(cId))
|
|
)
|
|
// Tri par score pondéré : priorité au premier critère cliqué
|
|
const n = criteres.value.length
|
|
const score = (o: Pratique) =>
|
|
criteres.value.reduce((s, cId, i) => {
|
|
return s + (o.criteres?.includes(cId) ? (n - i) : 0)
|
|
}, 0)
|
|
result = [...result].sort((a, b) => score(b) - score(a))
|
|
}
|
|
|
|
if (typesEntite.value.length) {
|
|
result = result.filter((o) => o.type && typesEntite.value.includes(o.type))
|
|
}
|
|
|
|
if (pays.value.length) {
|
|
result = result.filter((o) => o.pays && pays.value.includes(o.pays))
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
// Séparation Europe / Outre-mer
|
|
const europeOrgs = computed<Pratique[]>(() =>
|
|
filtered.value.filter(o => !o.pays || (EUROPE_CODES as readonly string[]).includes(o.pays))
|
|
)
|
|
|
|
const outremerOrgs = computed<Pratique[]>(() =>
|
|
filtered.value.filter(o => o.pays && (OUTREMER_CODES as readonly string[]).includes(o.pays))
|
|
)
|
|
|
|
// ── Compteurs ─────────────────────────────────────────────────────────────
|
|
const critereCount = computed<Record<number, number>>(() => {
|
|
const counts: Record<number, number> = {}
|
|
CRITERES.forEach(c => { counts[c.id] = 0 })
|
|
pratiques.value.forEach(o => {
|
|
o.criteres?.forEach(cId => { counts[cId] = (counts[cId] ?? 0) + 1 })
|
|
})
|
|
return counts
|
|
})
|
|
|
|
const typeCount = computed<Record<string, number>>(() => {
|
|
const counts: Record<string, number> = {}
|
|
TYPES_ENTITE.forEach(t => { counts[t] = 0 })
|
|
pratiques.value.forEach(o => {
|
|
if (o.type) counts[o.type] = (counts[o.type] ?? 0) + 1
|
|
})
|
|
return counts
|
|
})
|
|
|
|
useHead({ title: 'AEP — Pratiques régénératives en Europe' })
|
|
</script>
|