feat(pratiques): types, API statique, composants filtres + cartes Europe/outremer
This commit is contained in:
170
pages/pratique/[id].vue
Normal file
170
pages/pratique/[id].vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="min-h-screen" style="background: var(--nav-bg);">
|
||||
<div class="max-w-4xl mx-auto px-4 py-6">
|
||||
|
||||
<!-- ── Bouton retour carte (préserve filtres URL) ─── -->
|
||||
<NuxtLink
|
||||
:to="retourUrl"
|
||||
class="inline-flex items-center gap-1.5 text-sm mb-6 rounded-lg px-3 py-1.5 transition-colors"
|
||||
style="color: var(--nav-text); background: var(--nav-bg-alt);"
|
||||
aria-label="Retour aux pratiques régénératives"
|
||||
>
|
||||
<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">
|
||||
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||
<polyline points="12 19 5 12 12 5"/>
|
||||
</svg>
|
||||
Retour aux pratiques régénératives
|
||||
</NuxtLink>
|
||||
|
||||
<!-- ── Chargement ──────────────────────────────────── -->
|
||||
<div v-if="pending" class="py-16 text-center text-sm" style="color: var(--nav-text-muted);">
|
||||
Chargement de la fiche…
|
||||
</div>
|
||||
|
||||
<!-- ── Erreur ──────────────────────────────────────── -->
|
||||
<div v-else-if="!pratique" class="py-16 text-center">
|
||||
<p class="text-lg font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
|
||||
<p class="text-sm" style="color: var(--nav-text-muted);">La pratique demandée n'existe pas ou a été supprimée.</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Contenu ─────────────────────────────────────── -->
|
||||
<template v-else>
|
||||
|
||||
<!-- Header fiche -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h1 class="text-2xl font-bold leading-tight" style="color: var(--nav-text);">{{ pratique.nom }}</h1>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-semibold uppercase tracking-wide"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ TYPES_ENTITE_LABELS[pratique.type] ?? pratique.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap mb-3">
|
||||
<span class="text-sm font-medium" style="color: var(--nav-text-muted);">
|
||||
{{ PAYS_LABELS[pratique.pays] ?? pratique.pays }}
|
||||
<template v-if="pratique.ville"> · {{ pratique.ville }}</template>
|
||||
</span>
|
||||
<span v-if="pratique.score" class="px-2 py-0.5 rounded text-xs" style="background: var(--nav-accent); color: var(--nav-text);">
|
||||
Score {{ pratique.score }}/5
|
||||
</span>
|
||||
<a
|
||||
v-if="pratique.url"
|
||||
:href="pratique.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline"
|
||||
style="color: var(--nav-primary-solid);"
|
||||
>Site web →</a>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p v-if="pratique.description" class="text-sm leading-relaxed" style="color: var(--nav-text);">
|
||||
{{ pratique.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Séparateur -->
|
||||
<div class="mb-6" style="height: 1px; background: var(--nav-bg-alt);"></div>
|
||||
|
||||
<!-- Critères régénératifs -->
|
||||
<div v-if="pratique.criteres?.length" class="mb-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Critères régénératifs</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="cId in pratique.criteres"
|
||||
:key="cId"
|
||||
class="px-3 py-1 rounded-full text-sm font-medium"
|
||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||
>
|
||||
{{ CRITERES.find(c => c.id === cId)?.label ?? `Critère ${cId}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="pratique.tags?.length" class="mb-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Tags</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="tag in pratique.tags"
|
||||
:key="tag"
|
||||
class="px-2 py-0.5 rounded text-xs"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Informations</h2>
|
||||
<dl class="space-y-1.5">
|
||||
<div v-if="pratique.passe" class="flex gap-2 text-sm">
|
||||
<dt style="color: var(--nav-text-muted);">Passe :</dt>
|
||||
<dd style="color: var(--nav-text);">{{ pratique.passe }}</dd>
|
||||
</div>
|
||||
<div v-if="pratique.source" class="flex gap-2 text-sm">
|
||||
<dt style="color: var(--nav-text-muted);">Source :</dt>
|
||||
<dd style="color: var(--nav-text);">{{ pratique.source }}</dd>
|
||||
</div>
|
||||
<div v-if="pratique.lat != null && pratique.lng != null" class="flex gap-2 text-sm">
|
||||
<dt style="color: var(--nav-text-muted);">Coordonnées :</dt>
|
||||
<dd style="color: var(--nav-text);">{{ pratique.lat }}, {{ pratique.lng }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Pratique } from '~/types/pratique'
|
||||
import { CRITERES, TYPES_ENTITE_LABELS, PAYS_LABELS } from '~/types/pratique'
|
||||
|
||||
// ── Params & route ────────────────────────────────────────────────────
|
||||
const route = useRoute()
|
||||
const pratiqueId = route.params.id as string
|
||||
|
||||
// ── Retour carte — préserve les filtres via sessionStorage ────────────
|
||||
const retourUrl = ref('/pratiques-regeneratives')
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = sessionStorage.getItem('pratiques_back_filters')
|
||||
if (stored) {
|
||||
retourUrl.value = `/pratiques-regeneratives?${stored}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Fetch toutes les pratiques et trouver la bonne ───────────────────
|
||||
const { data, pending } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques', {
|
||||
key: `pratiques-all`,
|
||||
})
|
||||
|
||||
const pratique = computed<Pratique | null>(() => {
|
||||
const id = parseInt(pratiqueId, 10)
|
||||
if (isNaN(id)) return null
|
||||
return data.value?.list?.find(p => p.id === id) ?? null
|
||||
})
|
||||
|
||||
// ── SEO dynamiques ────────────────────────────────────────────────────
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
pratique.value ? `${pratique.value.nom} — Pratiques régénératives — AEP` : 'Pratique régénérative — AEP'
|
||||
),
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: computed(() =>
|
||||
pratique.value?.description?.substring(0, 160).trim() ?? 'Pratique régénérative — AEP'
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
469
pages/pratiques-regeneratives.vue
Normal file
469
pages/pratiques-regeneratives.vue
Normal file
@@ -0,0 +1,469 @@
|
||||
<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 : Europe pleine largeur + DOM-TOM row en bas ── -->
|
||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||
<!-- Carte Europe — pleine largeur -->
|
||||
<div 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>
|
||||
</div>
|
||||
|
||||
<!-- Bandeau DOM-TOM — row horizontale pleine largeur, hauteur fixe -->
|
||||
<div
|
||||
class="shrink-0"
|
||||
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
|
||||
>
|
||||
<ClientOnly>
|
||||
<OutremerMapPratiques
|
||||
:orgs="outremerOrgs"
|
||||
:selectedId="selectedId"
|
||||
@select-org="onSelectPratique"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div
|
||||
class="flex items-center justify-center h-full 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">
|
||||
<span
|
||||
v-for="c in CRITERES"
|
||||
:key="c.id"
|
||||
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);'"
|
||||
@click="toggleCritere(c.id)"
|
||||
>{{ c.label }}</span>
|
||||
</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">
|
||||
<span
|
||||
v-for="t in TYPES_ENTITE"
|
||||
:key="t"
|
||||
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);'"
|
||||
@click="toggleType(t)"
|
||||
>{{ TYPES_ENTITE_LABELS[t] ?? t }}</span>
|
||||
</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) — désactivé V1 -->
|
||||
<button
|
||||
v-if="false"
|
||||
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.5;
|
||||
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="Chatbot (bientôt disponible)"
|
||||
disabled
|
||||
>
|
||||
<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>
|
||||
|
||||
</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')
|
||||
|
||||
// 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 = ''
|
||||
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>
|
||||
Reference in New Issue
Block a user