feat(pratiques): types, API statique, composants filtres + cartes Europe/outremer

This commit is contained in:
Jules Neny
2026-04-28 21:47:41 +02:00
parent 5eda4bd53d
commit a70c9e0b4f
10 changed files with 1611 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
<template>
<div>
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">CRITÈRES RÉGÉ</span>
<div class="flex flex-wrap gap-1">
<button
v-for="critere in CRITERES"
:key="critere.id"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(critere.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="toggle(critere.id)"
>
{{ critere.label }}
<span v-if="counts && counts[critere.id] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[critere.id] }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { CRITERES } from '~/types/pratique'
const props = defineProps<{
modelValue: number[]
counts?: Record<number, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: number[]]
}>()
function toggle(id: number) {
if (props.modelValue.includes(id)) {
emit('update:modelValue', props.modelValue.filter(v => v !== id))
} else {
emit('update:modelValue', [...props.modelValue, id])
}
}
</script>

224
components/EuropeMap.vue Normal file
View File

@@ -0,0 +1,224 @@
<template>
<div class="relative w-full h-full">
<div ref="mapContainer" class="w-full h-full rounded-none" />
</div>
</template>
<script setup lang="ts">
import type { Map, Marker, DivIcon } from 'leaflet'
interface Pratique {
id: number
nom: string
lat?: number | null
lng?: number | null
pays?: string
ville?: string
type?: string
score?: number
}
const props = defineProps<{
orgs: Pratique[]
selectedId?: number | null
}>()
const emit = defineEmits<{
'select-org': [id: number]
}>()
const mapContainer = ref<HTMLElement | null>(null)
let mapInstance: Map | null = null
let clusterGroup: any = null
const markers = new Map<number, Marker>()
let tileLayerInstance: any = null
function createPinIcon(score: number, isSelected = false): DivIcon {
const L = (window as any).L
// Couleur selon score (1-5) : du pale au vif
const bg = score >= 4 ? '#f5b342' : score >= 3 ? 'rgba(26,34,56,0.75)' : 'rgba(26,34,56,0.5)'
const border = isSelected ? '#f5b342' : '#ffffff'
const size = isSelected ? 18 : 14
const shadow = isSelected ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
return L.divIcon({
className: '',
html: `<div style="
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: ${bg};
border: 2px solid ${border};
box-shadow: ${shadow};
transition: all 0.2s;
"></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 4)],
})
}
async function initMap() {
if (!mapContainer.value) return
const Lmod = await import('leaflet')
const L: any = (Lmod as any).default || Lmod
await import('leaflet/dist/leaflet.css')
// @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.css')
// @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
;(window as any).L = L
// @ts-ignore
await import('leaflet.markercluster')
const MarkerClusterGroup = L.MarkerClusterGroup
mapInstance = L.map(mapContainer.value, {
center: [50.0, 10.0],
zoom: 4,
zoomControl: true,
attributionControl: true,
maxBounds: [[30.0, -15.0], [72.0, 40.0]],
maxBoundsViscosity: 0.8,
minZoom: 3,
maxZoom: 18,
})
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance = L.tileLayer(tileUrl, {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 19,
})
tileLayerInstance.addTo(mapInstance!)
clusterGroup = new MarkerClusterGroup({
disableClusteringAtZoom: 12,
maxClusterRadius: 50,
showCoverageOnHover: false,
iconCreateFunction: (cluster: any) => {
const count = cluster.getChildCount()
return L.divIcon({
html: `<div style="
width: 36px; height: 36px; border-radius: 50%;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px;
border: 2px solid white;
font-family: var(--nav-font);
">${count}</div>`,
className: '',
iconSize: [36, 36],
iconAnchor: [18, 18],
})
},
})
mapInstance.addLayer(clusterGroup)
updateMarkers(L)
}
function updateMarkers(L?: any) {
if (!mapInstance || !clusterGroup) return
const leaflet = L || (window as any).L
if (!leaflet) return
clusterGroup.clearLayers()
markers.clear()
const orgsWithCoords = props.orgs.filter(
(o) => o.lat != null && o.lng != null
)
orgsWithCoords.forEach((org) => {
const isSelected = org.id === props.selectedId
const icon = createPinIcon(org.score ?? 1, isSelected)
const marker = leaflet.marker([org.lat!, org.lng!], { icon })
marker.bindPopup(`
<div style="font-family: var(--nav-font); min-width: 180px; padding: 4px 0;">
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 4px;">${org.nom}</div>
${org.pays ? `<div style="font-size: 11px; color: var(--nav-text-muted);">${org.pays}${org.ville ? ' · ' + org.ville : ''}</div>` : ''}
${org.type ? `<div style="font-size: 11px; color: var(--nav-text-muted); margin-top: 2px;">${org.type}</div>` : ''}
<a href="/pratique/${org.id}" style="
display: inline-block; margin-top: 8px; font-size: 12px;
color: var(--nav-primary-solid); text-decoration: underline;
">Voir la fiche →</a>
</div>
`, { maxWidth: 240 })
marker.on('click', () => emit('select-org', org.id))
markers.set(org.id, marker)
clusterGroup.addLayer(marker)
})
}
watch(
() => props.orgs,
() => updateMarkers(),
{ deep: false }
)
watch(
() => props.selectedId,
(newId, oldId) => {
if (!mapInstance) return
const leaflet = (window as any).L
if (!leaflet) return
if (oldId != null) {
const oldMarker = markers.get(oldId)
const oldOrg = props.orgs.find(o => o.id === oldId)
if (oldMarker && oldOrg) {
oldMarker.setIcon(createPinIcon(oldOrg.score ?? 1, false))
}
}
if (newId != null) {
const newMarker = markers.get(newId)
const newOrg = props.orgs.find(o => o.id === newId)
if (newMarker && newOrg) {
newMarker.setIcon(createPinIcon(newOrg.score ?? 1, true))
const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true })
}
}
}
)
function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L
if (!L) return
const url = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance.setUrl(url)
}
let themeObserver: MutationObserver | null = null
onMounted(() => {
initMap()
themeObserver = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark')
updateTileTheme(dark)
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
onUnmounted(() => {
themeObserver?.disconnect()
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
})
</script>

View File

@@ -0,0 +1,276 @@
<template>
<div class="outremer-accordion">
<div
v-for="dom in DOM_TOM_PRATIQUES"
:key="dom.code"
class="outremer-item"
>
<button
class="outremer-header"
@click="toggle(dom.code)"
:aria-expanded="openDom === dom.code"
>
<span class="outremer-title">{{ dom.label }}</span>
<span class="outremer-meta">
<span class="outremer-count-badge" :style="orgCounts[dom.code] === 0 ? 'opacity:0.4' : ''">
{{ orgCounts[dom.code] ?? 0 }} fiche{{ (orgCounts[dom.code] ?? 0) > 1 ? 's' : '' }}
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
aria-hidden="true"
class="outremer-chevron"
:class="{ 'outremer-chevron--open': openDom === dom.code }"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
</button>
<div
v-show="openDom === dom.code"
class="outremer-map-container"
>
<div :ref="el => { if (el) mapRefs[dom.code] = el as HTMLElement }" class="outremer-map" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Map as LeafletMap, TileLayer } from 'leaflet'
interface Pratique {
id: number
nom: string
lat?: number | null
lng?: number | null
pays?: string
ville?: string
type?: string
score?: number
}
const DOM_TOM_PRATIQUES = [
{ code: 'GP', label: 'Guadeloupe', center: [16.25, -61.58] as [number, number], zoom: 9 },
{ code: 'MQ', label: 'Martinique', center: [14.65, -61.02] as [number, number], zoom: 9 },
{ code: 'GF', label: 'Guyane', center: [4.0, -53.0] as [number, number], zoom: 6 },
{ code: 'RE', label: 'La Réunion', center: [-21.11, 55.53] as [number, number], zoom: 9 },
{ code: 'YT', label: 'Mayotte', center: [-12.83, 45.16] as [number, number], zoom: 10 },
{ code: 'PF', label: 'Polynésie française', center: [-17.5, -149.5] as [number, number], zoom: 8 },
{ code: 'NC', label: 'Nouvelle-Calédonie', center: [-20.9, 165.6] as [number, number], zoom: 7 },
]
const props = defineProps<{
orgs: Pratique[]
selectedId?: number | null
}>()
const emit = defineEmits<{
'select-org': [id: number]
}>()
const mapRefs: Record<string, HTMLElement> = {}
const mapInstances: Record<string, LeafletMap> = {}
const tileLayers: Record<string, TileLayer> = {}
const openDom = ref<string | null>(null)
const orgCounts = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
DOM_TOM_PRATIQUES.forEach(d => { counts[d.code] = 0 })
props.orgs.forEach(o => {
if (o.pays && counts[o.pays] !== undefined) {
counts[o.pays]++
}
})
return counts
})
function toggle(code: string) {
openDom.value = openDom.value === code ? null : code
nextTick(() => {
if (openDom.value === code && !mapInstances[code]) {
initSingleMap(code)
} else if (openDom.value === code) {
mapInstances[code]?.invalidateSize()
}
})
}
function createPinIcon(L: any, score: number, isSelected = false) {
const bg = score >= 4 ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
const border = isSelected ? '#f5b342' : '#ffffff'
const size = isSelected ? 16 : 12
return L.divIcon({
className: '',
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${bg};border:2px solid ${border};"></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 4)],
})
}
function getTileUrl(dark: boolean) {
return dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
}
async function initSingleMap(code: string) {
const dom = DOM_TOM_PRATIQUES.find(d => d.code === code)
if (!dom) return
const Lmod = await import('leaflet')
const L: any = (Lmod as any).default || Lmod
await import('leaflet/dist/leaflet.css')
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const el = mapRefs[code]
if (!el) return
const map = L.map(el, {
center: dom.center, zoom: dom.zoom,
zoomControl: false, attributionControl: false,
dragging: true, scrollWheelZoom: true, doubleClickZoom: true,
touchZoom: true, keyboard: false,
})
const tileLayer = L.tileLayer(getTileUrl(isDark), {
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
})
tileLayer.addTo(map)
tileLayers[code] = tileLayer as unknown as TileLayer
mapInstances[code] = map as unknown as LeafletMap
renderPins(L, code)
}
function updateTheme(dark: boolean) {
const url = getTileUrl(dark)
Object.values(tileLayers).forEach(tl => {
(tl as any).setUrl(url)
})
}
function renderPins(L: any, code: string) {
const map = mapInstances[code] as any
if (!map) return
if (map._navMarkers) {
map._navMarkers.forEach((m: any) => m.remove())
}
map._navMarkers = []
const domOrgs = props.orgs.filter(o => o.pays === code && o.lat != null && o.lng != null)
domOrgs.forEach(org => {
const icon = createPinIcon(L, org.score ?? 1, org.id === props.selectedId)
const marker = L.marker([org.lat!, org.lng!], { icon })
marker.bindPopup(`
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:160px;padding:4px 0;">
<div style="font-weight:700;color:#1a2238;margin-bottom:2px;">${org.nom}</div>
${org.ville ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);">${org.ville}</div>` : ''}
${org.type ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);margin-top:2px;">${org.type}</div>` : ''}
<a href="/pratique/${org.id}" style="display:inline-block;margin-top:8px;font-size:12px;color:#1a2238;text-decoration:underline;">Voir la fiche →</a>
</div>
`, { maxWidth: 200 })
marker.on('click', () => emit('select-org', org.id))
marker.addTo(map)
map._navMarkers.push(marker)
})
}
watch(() => props.orgs, () => {
DOM_TOM_PRATIQUES.forEach(dom => {
if (mapInstances[dom.code]) {
import('leaflet').then(L => renderPins(L, dom.code))
}
})
}, { deep: false })
watch(() => props.selectedId, () => {
DOM_TOM_PRATIQUES.forEach(dom => {
if (mapInstances[dom.code]) {
import('leaflet').then(L => renderPins(L, dom.code))
}
})
})
let themeObserver: MutationObserver | null = null
onMounted(() => {
themeObserver = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark')
updateTheme(dark)
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
onUnmounted(() => {
themeObserver?.disconnect()
Object.values(mapInstances).forEach(m => (m as any).remove())
})
</script>
<style scoped>
.outremer-accordion {
display: flex;
flex-direction: column;
width: 100%;
}
.outremer-item {
border-bottom: 1px solid var(--nav-bg-alt);
}
.outremer-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
background: var(--nav-surface);
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.outremer-header:hover {
background: var(--nav-bg-alt);
}
.outremer-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
}
.outremer-meta {
display: flex;
align-items: center;
gap: 8px;
}
.outremer-count-badge {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.outremer-chevron {
color: var(--nav-text-muted);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.outremer-chevron--open {
transform: rotate(180deg);
}
.outremer-map-container {
height: 220px;
border-top: 1px solid var(--nav-bg-alt);
}
.outremer-map {
width: 100%;
height: 100%;
}
</style>

60
components/PaysFilter.vue Normal file
View File

@@ -0,0 +1,60 @@
<template>
<div>
<!-- Groupe Europe -->
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">PAYS EUROPE</span>
<div class="flex flex-wrap gap-1 mb-2">
<button
v-for="code in EUROPE_CODES"
:key="code"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(code)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(code)"
>
{{ PAYS_LABELS[code] ?? code }}
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
</button>
</div>
<!-- Groupe Outre-mer -->
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">OUTRE-MER</span>
<div class="flex flex-wrap gap-1">
<button
v-for="code in OUTREMER_CODES"
:key="code"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(code)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(code)"
>
{{ PAYS_LABELS[code] ?? code }}
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { EUROPE_CODES, OUTREMER_CODES, PAYS_LABELS } from '~/types/pratique'
const props = defineProps<{
modelValue: string[]
counts?: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function toggle(code: string) {
if (props.modelValue.includes(code)) {
emit('update:modelValue', props.modelValue.filter(v => v !== code))
} else {
emit('update:modelValue', [...props.modelValue, code])
}
}
</script>

View File

@@ -0,0 +1,241 @@
<template>
<aside
class="flex flex-col h-full overflow-hidden"
style="background: var(--nav-surface); border-right: 1px solid var(--nav-bg-alt);"
>
<!-- BARRE DE RECHERCHE -->
<div
class="shrink-0 px-4 pt-4 pb-3 border-b"
style="border-color: var(--nav-bg-alt);"
>
<label class="sidebar-search-label" aria-label="Rechercher une pratique">
<svg
width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
class="sidebar-search-icon"
>
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref="searchInputEl"
:value="search"
type="search"
placeholder="Rechercher une pratique…"
class="sidebar-search-input"
autocomplete="off"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer la recherche"
@click.stop="emit('update:search', '')"
>
<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>
</div>
<!-- FILTRES -->
<div
class="shrink-0 px-4 pt-3 pb-3 space-y-4 border-b overflow-y-auto"
style="border-color: var(--nav-bg-alt); max-height: 280px;"
>
<!-- Critères régé -->
<CritereFilter
:modelValue="criteres"
:counts="critereCount"
@update:modelValue="emit('update:criteres', $event)"
/>
<!-- Type entité -->
<TypeEntiteFilter
:modelValue="typesEntite"
:counts="typeCount"
@update:modelValue="emit('update:typesEntite', $event)"
/>
</div>
<!-- LISTE FICHES -->
<div class="flex-1 flex flex-col min-h-0">
<div
class="shrink-0 flex items-center justify-between px-4 py-2 border-b"
style="border-color: var(--nav-bg-alt);"
>
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ resultCount }} résultat{{ resultCount > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="emit('reset-filters')"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>
Effacer les filtres
</button>
</div>
<div class="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
<div
v-if="pending"
class="flex items-center justify-center py-8"
style="color: var(--nav-text-muted);"
>
Chargement
</div>
<div v-else-if="pratiques.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<div
v-for="pratique in pratiques"
:key="pratique.id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === pratique.id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent); padding-left: 9px;'
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="emit('select-pratique', pratique.id)"
@mouseenter="emit('hover-pratique', pratique.id)"
@mouseleave="emit('hover-pratique', null)"
>
<div class="flex items-start justify-between gap-1.5">
<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-1.5 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); margin-top: 1px;"
>{{ 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-0.5 text-xs" style="color: var(--nav-text-muted);">
{{ pratique.ville }}
</div>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { CRITERES } from '~/types/pratique'
interface Pratique {
id: number
nom: string
pays?: string
ville?: string
type?: string
criteres?: number[]
score?: number
}
const props = defineProps<{
search: string
criteres: number[]
typesEntite: string[]
critereCount: Record<number, number>
typeCount: Record<string, number>
resultCount: number
pratiques: Pratique[]
selectedId: number | null
hasActiveFilters: boolean
pending?: boolean
}>()
const emit = defineEmits<{
'update:search': [value: string]
'update:criteres': [value: number[]]
'update:typesEntite': [value: string[]]
'select-pratique': [id: number]
'hover-pratique': [id: number | null]
'reset-filters': []
}>()
const searchInputEl = ref<HTMLInputElement | null>(null)
</script>
<style scoped>
.sidebar-search-label {
display: flex;
align-items: center;
gap: 8px;
border: 1.5px solid var(--nav-bg-alt);
border-radius: 10px;
background: var(--nav-bg);
padding: 7px 10px;
cursor: text;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s;
}
.sidebar-search-label:focus-within {
border-color: var(--nav-primary);
background: var(--nav-surface);
}
.sidebar-search-icon {
color: var(--nav-text-muted);
flex-shrink: 0;
transition: color 0.2s;
}
.sidebar-search-label:focus-within .sidebar-search-icon {
color: var(--nav-primary-solid);
}
.sidebar-search-input {
border: none;
outline: none;
background: transparent;
color: var(--nav-text);
font-size: 13px;
width: 100%;
min-width: 0;
font-family: var(--nav-font);
}
.sidebar-search-input::placeholder {
color: var(--nav-text-muted);
}
.sidebar-search-input::-webkit-search-cancel-button {
display: none;
}
.sidebar-search-clear {
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted);
flex-shrink: 0;
padding: 2px;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
background: transparent;
border: none;
cursor: pointer;
}
.sidebar-search-clear:hover {
color: var(--nav-text);
background: var(--nav-bg-alt);
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div>
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">TYPE D'ENTITÉ</span>
<div class="flex flex-wrap gap-1">
<button
v-for="type in TYPES_ENTITE"
:key="type"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(type)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(type)"
>
{{ TYPES_ENTITE_LABELS[type] ?? type }}
<span v-if="counts && counts[type] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[type] }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { TYPES_ENTITE, TYPES_ENTITE_LABELS } from '~/types/pratique'
const props = defineProps<{
modelValue: string[]
counts?: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function toggle(type: string) {
if (props.modelValue.includes(type)) {
emit('update:modelValue', props.modelValue.filter(v => v !== type))
} else {
emit('update:modelValue', [...props.modelValue, type])
}
}
</script>

170
pages/pratique/[id].vue Normal file
View 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>

View 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>

View File

@@ -0,0 +1,20 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import type { Pratique } from '~/types/pratique'
/**
* GET /api/pratiques
* Lit public/data/pratiques-regeneratives.json
* Retourne { list: Pratique[], source: 'static' }
*/
export default defineEventHandler(async (_event) => {
try {
const jsonPath = resolve(process.cwd(), 'public/data/pratiques-regeneratives.json')
const raw = readFileSync(jsonPath, 'utf-8')
const list: Pratique[] = JSON.parse(raw)
return { list, source: 'static' }
} catch (err) {
console.error('[PRATIQUES API] Erreur lecture JSON:', err)
throw createError({ statusCode: 503, message: 'Données pratiques-regeneratives indisponibles' })
}
})

69
types/pratique.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* Interface canonique Pratique — AEP Pratiques Régénératives
* Source unique : types/pratique.ts
* Importée dans pages/pratiques-regeneratives.vue, pages/pratique/[id].vue
*/
export interface Pratique {
id: number
nom: string
pays: string // ISO-2 — Europe (FR/BE/...) ou DOM-TOM (GP/MQ/GF/RE/YT/PF/NC/...)
ville: string
type: 'agence' | 'cooperative' | 'collectif' | 'reseau' | 'asso' | 'recherche' | 'mouvement' | 'plateforme' | 'inconnu'
url: string
lat: number | null
lng: number | null
description: string
criteres: number[] // 1-8
score: number
tags: string[]
source: string
passe: 1 | 2 | 3
}
export const CRITERES = [
{ id: 1, label: 'Matériaux' },
{ id: 2, label: 'Filières' },
{ id: 3, label: 'Posture' },
{ id: 4, label: 'Process' },
{ id: 5, label: 'Politique' },
{ id: 6, label: 'Modèle éco' },
{ id: 7, label: 'Vivant' },
{ id: 8, label: 'Transmission' },
] as const
export const TYPES_ENTITE = [
'agence',
'cooperative',
'collectif',
'reseau',
'asso',
'recherche',
'mouvement',
'plateforme',
'inconnu',
] as const
export const TYPES_ENTITE_LABELS: Record<string, string> = {
agence: 'Agence',
cooperative: 'Coopérative',
collectif: 'Collectif',
reseau: 'Réseau',
asso: 'Association',
recherche: 'Recherche',
mouvement: 'Mouvement',
plateforme: 'Plateforme',
inconnu: 'Autre',
}
export const EUROPE_CODES = ['FR', 'BE', 'UK', 'DE', 'ES', 'NL', 'CH', 'IT', 'PT', 'SE', 'DK', 'FI', 'NO', 'PL', 'CZ', 'AT'] as const
export const OUTREMER_CODES = ['GP', 'MQ', 'GF', 'RE', 'YT', 'PF', 'NC', 'BL', 'MF', 'PM', 'WF'] as const
export const PAYS_LABELS: Record<string, string> = {
FR: 'France', BE: 'Belgique', UK: 'Royaume-Uni', DE: 'Allemagne',
ES: 'Espagne', NL: 'Pays-Bas', CH: 'Suisse', IT: 'Italie',
PT: 'Portugal', SE: 'Suède', DK: 'Danemark', FI: 'Finlande',
NO: 'Norvège', PL: 'Pologne', CZ: 'Tchéquie', AT: 'Autriche',
GP: 'Guadeloupe', MQ: 'Martinique', GF: 'Guyane', RE: 'La Réunion',
YT: 'Mayotte', PF: 'Polynésie française', NC: 'Nouvelle-Calédonie',
BL: 'Saint-Barthélemy', MF: 'Saint-Martin', PM: 'Saint-Pierre-et-Miquelon', WF: 'Wallis-et-Futuna',
}