templates/search/index.html.twig line 1

Open in your IDE?
  1. {% extends 'base.html.twig' %}
  2. {% block title %}Munganyo - Recherche d'actes et publications{% endblock %}
  3. {% block body %}
  4. {# ================================================================
  5. MUNGANYO — Moteur Ibunas.IA
  6. CDC: 3 filtres obligatoires + enrichissement IA (Premium)
  7. Institutions, Chronologique, Type d'acte/annonces
  8. JS dynamique: debounce, AJAX, filter chips, autocomplete
  9. ================================================================ #}
  10. <!-- Hero Section Munganyo -->
  11. <section class="relative bg-gradient-to-br from-[#004d26] via-[#006633] to-[#008040] text-white overflow-hidden">
  12. <div class="absolute inset-0 opacity-10 pointer-events-none">
  13. <div class="absolute top-10 left-0 w-80 h-80 bg-white rounded-full blur-3xl"></div>
  14. <div class="absolute bottom-0 right-0 w-[30rem] h-[30rem] bg-[#00994d] rounded-full blur-3xl"></div>
  15. <!-- Pattern géométrique subtil -->
  16. <svg class="absolute inset-0 w-full h-full opacity-20" xmlns="http://www.w3.org/2000/svg">
  17. <pattern id="hex" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
  18. <path d="M20 2 L38 12 L38 28 L20 38 L2 28 L2 12 Z" fill="none" stroke="white" stroke-width="0.5"/>
  19. </pattern>
  20. <rect width="100%" height="100%" fill="url(#hex)" />
  21. </svg>
  22. </div>
  23. <div class="container mx-auto px-4 py-16 relative z-10">
  24. <div class="max-w-4xl mx-auto text-center">
  25. <!-- Badge moteur -->
  26. <div class="inline-flex items-center bg-white/15 backdrop-blur-sm rounded-full px-5 py-2 mb-5 border border-white/25">
  27. <span class="w-2 h-2 rounded-full bg-emerald-300 animate-pulse mr-2"></span>
  28. <span class="text-sm font-semibold tracking-wide">Moteur Ibunas.IA
  29. {% if isPremium is defined and isPremium %}
  30. <span class="ml-2 bg-amber-400 text-amber-900 text-xs font-bold px-2 py-0.5 rounded-full">PREMIUM</span>
  31. {% endif %}
  32. </span>
  33. </div>
  34. <h1 class="text-5xl md:text-6xl font-black mb-4 tracking-tight">
  35. Munganyo
  36. <span class="block text-2xl md:text-3xl font-light opacity-90 mt-2">Recherche avancée · Journal Officiel des Comores</span>
  37. </h1>
  38. <p class="text-white/80 mb-8 max-w-2xl mx-auto text-base leading-relaxed">
  39. Accédez à l'ensemble des textes juridiques, actes officiels et annonces légales publiés au Journal Officiel de l'Union des Comores.
  40. </p>
  41. <!-- Barre de recherche principale -->
  42. <div class="max-w-2xl mx-auto" id="hero-search-wrapper">
  43. <div class="relative">
  44. <i class="fas fa-search absolute left-5 top-1/2 -translate-y-1/2 text-gray-400 text-lg z-10"></i>
  45. <input
  46. type="text"
  47. id="main-search-input"
  48. name="q"
  49. value="{{ query|default('') }}"
  50. placeholder="Rechercher un décret, un arrêté, une annonce légale..."
  51. autocomplete="off"
  52. class="w-full pl-14 pr-36 py-5 text-gray-800 text-base rounded-full border-none shadow-2xl focus:outline-none focus:ring-4 focus:ring-[#006633]/40 transition-all duration-300"
  53. >
  54. <button id="hero-search-btn"
  55. class="absolute right-2 top-1/2 -translate-y-1/2 bg-gradient-to-r from-[#006633] to-[#008040] text-white px-6 py-2.5 rounded-full font-semibold hover:from-[#004d26] hover:to-[#006633] transition-all duration-300 shadow-md hover:shadow-lg">
  56. <span class="hidden md:inline"><i class="fas fa-arrow-right mr-1"></i> Rechercher</span>
  57. <span class="md:hidden"><i class="fas fa-arrow-right"></i></span>
  58. </button>
  59. <!-- Autocomplete dropdown -->
  60. <div id="autocomplete-dropdown"
  61. class="absolute top-full left-0 right-0 mt-2 bg-white rounded-2xl shadow-2xl border border-gray-100 hidden z-50 overflow-hidden">
  62. </div>
  63. </div>
  64. <!-- Suggestions rapides -->
  65. <div class="flex flex-wrap justify-center gap-2 mt-4">
  66. <span class="text-white/50 text-xs">Tendances :</span>
  67. {% for suggestion in ['Loi de finances', 'Décret nomination', 'Marché public', 'Acte foncier Moroni', 'Récépissé association'] %}
  68. <button type="button" class="quick-suggestion text-white/80 hover:text-white text-xs underline-offset-2 hover:underline transition">{{ suggestion }}</button>
  69. {% endfor %}
  70. </div>
  71. </div>
  72. <!-- Filtres rapides par type -->
  73. <div class="mt-8 flex flex-wrap justify-center gap-2" id="quick-type-filters">
  74. {% set quickTypes = [
  75. {val: '', label: 'Tous', icon: 'fa-layer-group'},
  76. {val: 'loi', label: 'Lois', icon: 'fa-gavel'},
  77. {val: 'decret', label: 'Décrets', icon: 'fa-file-signature'},
  78. {val: 'arrete', label: 'Arrêtés', icon: 'fa-clipboard-list'},
  79. {val: 'marche_public', label: 'Marchés publics', icon: 'fa-briefcase'},
  80. {val: 'acte_foncier', label: 'Foncier', icon: 'fa-map-marker-alt'},
  81. {val: 'enchere', label: 'Enchères', icon: 'fa-hammer'},
  82. ] %}
  83. {% for qt in quickTypes %}
  84. <button type="button"
  85. data-type="{{ qt.val }}"
  86. class="quick-type-btn px-4 py-2 rounded-full text-sm font-medium transition-all duration-200
  87. {% if (typeFilter is defined and typeFilter == qt.val) or (qt.val == '' and (typeFilter is not defined or typeFilter == '')) %}
  88. bg-white text-[#006633] shadow-lg
  89. {% else %}
  90. bg-white/20 text-white hover:bg-white/30 border border-white/20
  91. {% endif %}">
  92. <i class="fas {{ qt.icon }} mr-1 text-xs"></i> {{ qt.label }}
  93. </button>
  94. {% endfor %}
  95. </div>
  96. </div>
  97. </div>
  98. <div class="absolute bottom-0 left-0 right-0">
  99. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 80" class="w-full">
  100. <path fill="#f3f4f6" fill-opacity="1" d="M0,40L60,45C120,50,240,60,360,62C480,64,600,58,720,52C840,46,960,40,1080,42C1200,44,1320,54,1380,58L1440,62L1440,80L0,80Z"></path>
  101. </svg>
  102. </div>
  103. </section>
  104. <!-- ============================================================
  105. FILTRES AVANCÉS (CDC: 3 filtres officiels + 2 supplémentaires)
  106. ============================================================ -->
  107. <section class="py-8 bg-gray-50" id="filters-section">
  108. <div class="container mx-auto px-4">
  109. <div class="max-w-6xl mx-auto">
  110. <!-- Header filtres + toggle -->
  111. <div class="flex flex-wrap items-center justify-between gap-3 mb-4">
  112. <div class="flex items-center gap-3">
  113. <h2 class="text-sm font-bold text-gray-700 uppercase tracking-widest">
  114. <i class="fas fa-sliders-h text-[#006633] mr-2"></i> Affiner la recherche
  115. </h2>
  116. <!-- Compteur filtres actifs -->
  117. <span id="active-filters-count"
  118. class="hidden bg-[#006633] text-white text-xs font-bold px-2.5 py-1 rounded-full">
  119. 0
  120. </span>
  121. </div>
  122. <div class="flex items-center gap-3">
  123. <button id="toggle-filters-btn"
  124. class="flex items-center gap-2 text-sm text-gray-600 hover:text-[#006633] transition font-medium">
  125. <i class="fas fa-chevron-down transition-transform duration-300" id="toggle-filters-icon"></i>
  126. <span id="toggle-filters-label">Masquer les filtres</span>
  127. </button>
  128. <button id="reset-filters-btn"
  129. class="hidden text-sm text-red-500 hover:text-red-700 transition font-medium">
  130. <i class="fas fa-times-circle mr-1"></i> Réinitialiser
  131. </button>
  132. </div>
  133. </div>
  134. <!-- Chips des filtres actifs -->
  135. <div id="active-filter-chips" class="flex flex-wrap gap-2 mb-4 empty:hidden"></div>
  136. <!-- Panel des filtres -->
  137. <div id="filters-panel" class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden transition-all duration-300">
  138. <form id="advanced-search-form" class="p-5">
  139. <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
  140. <!-- FILTRE 1 : Institution émettrice (CDC) -->
  141. <div class="xl:col-span-2">
  142. <label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
  143. <i class="fas fa-landmark text-[#006633] mr-1"></i> Institution émettrice
  144. </label>
  145. <select name="institution" id="filter-institution"
  146. class="search-filter w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#006633]/40 bg-white">
  147. <option value="">Toutes les institutions</option>
  148. <optgroup label="🇰🇲 Union des Comores">
  149. <option value="presidence" {% if filters.institution is defined and filters.institution == 'presidence' %}selected{% endif %}>Présidence de l'Union</option>
  150. <option value="assemblee_nationale" {% if filters.institution is defined and filters.institution == 'assemblee_nationale' %}selected{% endif %}>Assemblée Nationale</option>
  151. <option value="sgg" {% if filters.institution is defined and filters.institution == 'sgg' %}selected{% endif %}>Secrétariat Général du Gouvernement</option>
  152. <option value="vice_presidence" {% if filters.institution is defined and filters.institution == 'vice_presidence' %}selected{% endif %}>Vice-Présidence</option>
  153. <option value="finances" {% if filters.institution is defined and filters.institution == 'finances' %}selected{% endif %}>Ministère des Finances</option>
  154. <option value="justice" {% if filters.institution is defined and filters.institution == 'justice' %}selected{% endif %}>Ministère de la Justice</option>
  155. <option value="interieur" {% if filters.institution is defined and filters.institution == 'interieur' %}selected{% endif %}>Ministère de l'Intérieur</option>
  156. <option value="sante" {% if filters.institution is defined and filters.institution == 'sante' %}selected{% endif %}>Ministère de la Santé</option>
  157. <option value="education" {% if filters.institution is defined and filters.institution == 'education' %}selected{% endif %}>Ministère de l'Éducation</option>
  158. <option value="transport" {% if filters.institution is defined and filters.institution == 'transport' %}selected{% endif %}>Ministère du Transport</option>
  159. <option value="agriculture" {% if filters.institution is defined and filters.institution == 'agriculture' %}selected{% endif %}>Ministère de l'Agriculture</option>
  160. <option value="travaux_publics" {% if filters.institution is defined and filters.institution == 'travaux_publics' %}selected{% endif %}>Ministère des Travaux Publics</option>
  161. <option value="autres_ministeres" {% if filters.institution is defined and filters.institution == 'autres_ministeres' %}selected{% endif %}>Autres ministères</option>
  162. </optgroup>
  163. <optgroup label="🏛 Gouvernorats">
  164. <option value="gouvernorat_ngazidja" {% if filters.institution is defined and filters.institution == 'gouvernorat_ngazidja' %}selected{% endif %}>Gouvernorat de Ngazidja (Grande Comore)</option>
  165. <option value="gouvernorat_anjouan" {% if filters.institution is defined and filters.institution == 'gouvernorat_anjouan' %}selected{% endif %}>Gouvernorat d'Anjouan (Ndzuani)</option>
  166. <option value="gouvernorat_moheli" {% if filters.institution is defined and filters.institution == 'gouvernorat_moheli' %}selected{% endif %}>Gouvernorat de Mohéli (Mwali)</option>
  167. </optgroup>
  168. <optgroup label="⚖ Juridictions">
  169. <option value="justice_cour_supreme" {% if filters.institution is defined and filters.institution == 'justice_cour_supreme' %}selected{% endif %}>Cour Suprême</option>
  170. <option value="justice_cour_appel" {% if filters.institution is defined and filters.institution == 'justice_cour_appel' %}selected{% endif %}>Cour d'Appel</option>
  171. </optgroup>
  172. </select>
  173. </div>
  174. <!-- FILTRE 2 : Chronologique (CDC) -->
  175. <div>
  176. <label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
  177. <i class="fas fa-calendar-alt text-[#006633] mr-1"></i> Période
  178. </label>
  179. <select name="period" id="filter-period"
  180. class="search-filter w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#006633]/40 bg-white">
  181. <option value="">Toutes périodes</option>
  182. {% for yr in 2026..2018 %}
  183. <option value="{{ yr }}" {% if filters.period is defined and filters.period == (yr~'') %}selected{% endif %}>{{ yr }}</option>
  184. {% endfor %}
  185. <option value="custom" {% if filters.period is defined and filters.period == 'custom' %}selected{% endif %}>Période personnalisée</option>
  186. </select>
  187. <!-- Dates personnalisées -->
  188. <div id="custom-dates-panel" class="mt-2 space-y-1.5 {% if filters.period is not defined or filters.period != 'custom' %}hidden{% endif %}">
  189. <input type="date" name="date_debut" id="filter-date-debut"
  190. value="{{ filters.date_debut|default('') }}"
  191. class="search-filter w-full border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#006633]/40">
  192. <input type="date" name="date_fin" id="filter-date-fin"
  193. value="{{ filters.date_fin|default('') }}"
  194. class="search-filter w-full border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#006633]/40">
  195. </div>
  196. </div>
  197. <!-- FILTRE 3 : Type d'acte / annonces (CDC) -->
  198. <div>
  199. <label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
  200. <i class="fas fa-file-alt text-[#006633] mr-1"></i> Type d'acte
  201. </label>
  202. <select name="type" id="filter-type"
  203. class="search-filter w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#006633]/40 bg-white">
  204. <option value="">Tous les types</option>
  205. <optgroup label="📋 Actes officiels">
  206. <option value="loi" {% if filters.type is defined and filters.type == 'loi' %}selected{% endif %}>Lois</option>
  207. <option value="decret" {% if filters.type is defined and filters.type == 'decret' %}selected{% endif %}>Décrets</option>
  208. <option value="arrete" {% if filters.type is defined and filters.type == 'arrete' %}selected{% endif %}>Arrêtés</option>
  209. <option value="circulaire" {% if filters.type is defined and filters.type == 'circulaire' %}selected{% endif %}>Circulaires</option>
  210. <option value="avis" {% if filters.type is defined and filters.type == 'avis' %}selected{% endif %}>Avis officiels</option>
  211. </optgroup>
  212. <optgroup label="📢 Annonces légales">
  213. <option value="marche_public" {% if filters.type is defined and filters.type == 'marche_public' %}selected{% endif %}>Marchés publics & DSP</option>
  214. <option value="acte_foncier" {% if filters.type is defined and filters.type == 'acte_foncier' %}selected{% endif %}>Réquisitions & actes fonciers</option>
  215. <option value="societe_rccm" {% if filters.type is defined and filters.type == 'societe_rccm' %}selected{% endif %}>Actes RCCM/OHADA (sociétés)</option>
  216. <option value="enchere" {% if filters.type is defined and filters.type == 'enchere' %}selected{% endif %}>Ventes aux enchères publiques</option>
  217. <option value="association" {% if filters.type is defined and filters.type == 'association' %}selected{% endif %}>Récépissés associations</option>
  218. </optgroup>
  219. </select>
  220. </div>
  221. <!-- FILTRE 4 : Statut juridique (CDC: En vigueur / Abrogé…) -->
  222. <div>
  223. <label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
  224. <i class="fas fa-balance-scale text-[#006633] mr-1"></i> Statut juridique
  225. </label>
  226. <select name="statut" id="filter-statut"
  227. class="search-filter w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#006633]/40 bg-white">
  228. <option value="">Tous statuts</option>
  229. <option value="en_vigueur" {% if filters.statut is defined and filters.statut == 'en_vigueur' %}selected{% endif %}>✅ En vigueur</option>
  230. <option value="modifie" {% if filters.statut is defined and filters.statut == 'modifie' %}selected{% endif %}>✏️ Modifié</option>
  231. <option value="abroge" {% if filters.statut is defined and filters.statut == 'abroge' %}selected{% endif %}>❌ Abrogé</option>
  232. <option value="suspendu" {% if filters.statut is defined and filters.statut == 'suspendu' %}selected{% endif %}>⏸️ Suspendu</option>
  233. <option value="remplace" {% if filters.statut is defined and filters.statut == 'remplace' %}selected{% endif %}>🔄 Remplacé</option>
  234. <option value="archive" {% if filters.statut is defined and filters.statut == 'archive' %}selected{% endif %}>📦 Archivé</option>
  235. </select>
  236. </div>
  237. </div>
  238. <!-- Ligne 2 : Territoire + tri + submit -->
  239. <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-4 pt-4 border-t border-gray-100">
  240. <!-- Territoire applicable (CDC) -->
  241. <div>
  242. <label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
  243. <i class="fas fa-map-marker-alt text-[#006633] mr-1"></i> Territoire applicable
  244. </label>
  245. <select name="territoire" id="filter-territoire"
  246. class="search-filter w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#006633]/40 bg-white">
  247. <option value="">Toute l'Union</option>
  248. <option value="union" {% if filters.territoire is defined and filters.territoire == 'union' %}selected{% endif %}>Union des Comores</option>
  249. <option value="ngazidja" {% if filters.territoire is defined and filters.territoire == 'ngazidja' %}selected{% endif %}>Grande Comore (Ngazidja)</option>
  250. <option value="anjouan" {% if filters.territoire is defined and filters.territoire == 'anjouan' %}selected{% endif %}>Anjouan (Ndzuani)</option>
  251. <option value="moheli" {% if filters.territoire is defined and filters.territoire == 'moheli' %}selected{% endif %}>Mohéli (Mwali)</option>
  252. <option value="moroni" {% if filters.territoire is defined and filters.territoire == 'moroni' %}selected{% endif %}>Moroni</option>
  253. <option value="mutsamudu" {% if filters.territoire is defined and filters.territoire == 'mutsamudu' %}selected{% endif %}>Mutsamudu</option>
  254. <option value="fomboni" {% if filters.territoire is defined and filters.territoire == 'fomboni' %}selected{% endif %}>Fomboni</option>
  255. </select>
  256. </div>
  257. <!-- Tri -->
  258. <div>
  259. <label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">
  260. <i class="fas fa-sort text-[#006633] mr-1"></i> Trier par
  261. </label>
  262. <select name="sort" id="filter-sort"
  263. class="search-filter w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#006633]/40 bg-white">
  264. <option value="date_desc" {% if filters.sort is not defined or filters.sort == 'date_desc' %}selected{% endif %}>Date (récent → ancien)</option>
  265. <option value="date_asc" {% if filters.sort is defined and filters.sort == 'date_asc' %}selected{% endif %}>Date (ancien → récent)</option>
  266. <option value="pertinence" {% if filters.sort is defined and filters.sort == 'pertinence' %}selected{% endif %}>Pertinence</option>
  267. <option value="titre_asc" {% if filters.sort is defined and filters.sort == 'titre_asc' %}selected{% endif %}>Titre A → Z</option>
  268. </select>
  269. </div>
  270. <!-- Actions -->
  271. <div class="flex items-end gap-3">
  272. <button type="submit" id="apply-filters-btn"
  273. class="flex-1 bg-gradient-to-r from-[#006633] to-[#008040] text-white px-5 py-2.5 rounded-xl hover:from-[#004d26] hover:to-[#006633] transition shadow-md font-semibold text-sm">
  274. <i class="fas fa-search mr-2"></i> Appliquer
  275. </button>
  276. <a href="{{ path('app_search') }}"
  277. class="px-4 py-2.5 border border-gray-200 rounded-xl text-gray-500 hover:text-gray-700 hover:border-gray-300 transition text-sm font-medium">
  278. <i class="fas fa-undo-alt"></i>
  279. </a>
  280. </div>
  281. </div>
  282. </form>
  283. </div>
  284. </div>
  285. </div>
  286. </section>
  287. <!-- ============================================================
  288. ZONE DE RÉSULTATS
  289. ============================================================ -->
  290. <section class="py-10 bg-white min-h-[40vh]" id="results-section">
  291. <div class="container mx-auto px-4">
  292. <div class="max-w-6xl mx-auto">
  293. <!-- Barre de statut résultats -->
  294. <div id="results-status-bar" class="flex flex-wrap items-center justify-between gap-3 mb-6 {% if query is not defined or query is empty %}hidden{% endif %}">
  295. <div class="flex items-center gap-3">
  296. <!-- Spinner loading (caché par défaut) -->
  297. <div id="search-spinner" class="hidden">
  298. <svg class="animate-spin w-5 h-5 text-[#006633]" fill="none" viewBox="0 0 24 24">
  299. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
  300. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
  301. </svg>
  302. </div>
  303. <h2 class="text-base font-bold text-gray-800" id="results-count-label">
  304. {% if query is defined and query is not empty %}
  305. <span id="count-number">{{ results|length }}</span> résultat(s) pour
  306. <em class="text-[#006633] not-italic font-black">"{{ query }}"</em>
  307. {% endif %}
  308. </h2>
  309. </div>
  310. <!-- Badge IA -->
  311. <div>
  312. {% if isPremium is defined and isPremium %}
  313. <div class="flex items-center gap-2 bg-gradient-to-r from-amber-50 to-yellow-50 border border-amber-200 text-amber-800 px-4 py-1.5 rounded-full text-sm font-semibold">
  314. <i class="fas fa-microchip text-amber-500"></i>
  315. Recherche enrichie Ibunas.IA activée
  316. </div>
  317. {% else %}
  318. <a href="{{ path('app_abonn') }}"
  319. class="flex items-center gap-2 bg-gray-50 border border-gray-200 text-gray-600 hover:border-[#006633] hover:text-[#006633] px-4 py-1.5 rounded-full text-sm font-medium transition">
  320. <i class="fas fa-lock text-xs"></i>
  321. Activer Ibunas.IA Premium
  322. </a>
  323. {% endif %}
  324. </div>
  325. </div>
  326. <!-- Liste des résultats (rendu SSR initial + remplacement AJAX) -->
  327. <div id="results-container">
  328. {% if query is defined and query is not empty %}
  329. {% if results is defined and results|length > 0 %}
  330. {% include '_partials/search_results_list.html.twig' with {results: results, isPremium: isPremium} %}
  331. {% else %}
  332. {% include '_partials/search_no_results.html.twig' with {query: query} %}
  333. {% endif %}
  334. {% else %}
  335. <!-- État d'accueil -->
  336. <div class="text-center py-16 bg-gradient-to-br from-gray-50 to-emerald-50/30 rounded-2xl border border-gray-100" id="welcome-state">
  337. <div class="w-20 h-20 bg-gradient-to-br from-[#006633] to-[#008040] rounded-2xl flex items-center justify-center mx-auto mb-5 shadow-lg">
  338. <i class="fas fa-search text-2xl text-white"></i>
  339. </div>
  340. <h2 class="text-2xl font-bold text-gray-800 mb-2">Que recherchez-vous ?</h2>
  341. <p class="text-gray-500 max-w-md mx-auto mb-6 text-sm">
  342. Textes législatifs, décrets présidentiels, arrêtés ministériels,
  343. marchés publics, actes fonciers… tout le Journal Officiel des Comores en un clic.
  344. </p>
  345. <div class="flex flex-wrap justify-center gap-3 text-xs text-gray-500">
  346. <span class="px-3 py-1.5 bg-white rounded-full shadow-sm border border-gray-100">
  347. <i class="fas fa-gavel text-purple-500 mr-1"></i> "Loi n°2026-…"
  348. </span>
  349. <span class="px-3 py-1.5 bg-white rounded-full shadow-sm border border-gray-100">
  350. <i class="fas fa-file-signature text-blue-500 mr-1"></i> "Décret nomination ministre"
  351. </span>
  352. <span class="px-3 py-1.5 bg-white rounded-full shadow-sm border border-gray-100">
  353. <i class="fas fa-map-marker-alt text-teal-500 mr-1"></i> "Actes fonciers Moroni"
  354. </span>
  355. <span class="px-3 py-1.5 bg-white rounded-full shadow-sm border border-gray-100">
  356. <i class="fas fa-briefcase text-emerald-500 mr-1"></i> "Appel d'offres travaux routiers"
  357. </span>
  358. </div>
  359. </div>
  360. {% endif %}
  361. </div>
  362. <!-- Pagination (rendu SSR + remplacé par AJAX) -->
  363. <div id="pagination-container">
  364. {% if totalPages is defined and totalPages > 1 %}
  365. <nav class="flex justify-center mt-8" aria-label="Pagination">
  366. <ul class="flex items-center gap-1">
  367. <li>
  368. <a href="{{ currentPage > 1 ? path('app_search', {page: currentPage - 1, q: query}) : '#' }}"
  369. class="flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-500 hover:border-[#006633] hover:text-[#006633] transition text-sm {% if currentPage <= 1 %}opacity-40 pointer-events-none{% endif %}">
  370. <i class="fas fa-chevron-left text-xs"></i>
  371. </a>
  372. </li>
  373. {% for page in 1..totalPages %}
  374. {% if page == currentPage %}
  375. <li><span class="flex items-center justify-center w-9 h-9 rounded-lg bg-[#006633] text-white font-bold text-sm">{{ page }}</span></li>
  376. {% elseif page <= 3 or page >= totalPages - 2 or (page >= currentPage - 2 and page <= currentPage + 2) %}
  377. <li>
  378. <a href="{{ path('app_search', {page: page, q: query}) }}"
  379. class="flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-600 hover:border-[#006633] hover:text-[#006633] transition text-sm">
  380. {{ page }}
  381. </a>
  382. </li>
  383. {% elseif page == 4 and currentPage > 6 %}
  384. <li><span class="px-2 text-gray-400">…</span></li>
  385. {% elseif page == totalPages - 3 and currentPage < totalPages - 5 %}
  386. <li><span class="px-2 text-gray-400">…</span></li>
  387. {% endif %}
  388. {% endfor %}
  389. <li>
  390. <a href="{{ currentPage < totalPages ? path('app_search', {page: currentPage + 1, q: query}) : '#' }}"
  391. class="flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-500 hover:border-[#006633] hover:text-[#006633] transition text-sm {% if currentPage >= totalPages %}opacity-40 pointer-events-none{% endif %}">
  392. <i class="fas fa-chevron-right text-xs"></i>
  393. </a>
  394. </li>
  395. </ul>
  396. </nav>
  397. {% endif %}
  398. </div>
  399. </div>
  400. </div>
  401. </section>
  402. <!-- ============================================================
  403. CONSEILS + STATS (bas de page)
  404. ============================================================ -->
  405. <section class="py-10 bg-gray-50 border-t border-gray-100">
  406. <div class="container mx-auto px-4">
  407. <div class="max-w-6xl mx-auto">
  408. <div class="grid grid-cols-1 md:grid-cols-3 gap-5">
  409. <div class="bg-white rounded-xl p-5 shadow-sm border border-gray-100 flex gap-4 items-start">
  410. <div class="w-10 h-10 bg-emerald-100 rounded-xl flex items-center justify-center flex-shrink-0">
  411. <i class="fas fa-lightbulb text-[#006633]"></i>
  412. </div>
  413. <div>
  414. <h3 class="font-semibold text-gray-800 text-sm mb-1">Recherche par mots-clés</h3>
  415. <p class="text-xs text-gray-500">Termes précis : "impôt", "nomination", "foncier", "appel d'offres bâtiment"</p>
  416. </div>
  417. </div>
  418. <div class="bg-white rounded-xl p-5 shadow-sm border border-gray-100 flex gap-4 items-start">
  419. <div class="w-10 h-10 bg-emerald-100 rounded-xl flex items-center justify-center flex-shrink-0">
  420. <i class="fas fa-hashtag text-[#006633]"></i>
  421. </div>
  422. <div>
  423. <h3 class="font-semibold text-gray-800 text-sm mb-1">Référence exacte</h3>
  424. <p class="text-xs text-gray-500">Numéro d'acte : "LOI-2026-015", "Décret 2026-045/PR"</p>
  425. </div>
  426. </div>
  427. <div class="bg-white rounded-xl p-5 shadow-sm border border-gray-100 flex gap-4 items-start">
  428. <div class="w-10 h-10 bg-amber-100 rounded-xl flex items-center justify-center flex-shrink-0">
  429. <i class="fas fa-microchip text-amber-600"></i>
  430. </div>
  431. <div>
  432. <h3 class="font-semibold text-gray-800 text-sm mb-1">Recherche Ibunas.IA <span class="text-amber-600 text-xs font-bold">PREMIUM</span></h3>
  433. <p class="text-xs text-gray-500">Recherche dans le contenu des actes via OCR et intelligence artificielle</p>
  434. </div>
  435. </div>
  436. </div>
  437. </div>
  438. </div>
  439. </section>
  440. {# ================================================================
  441. RÉSULTATS PARTIELS (macros Twig inline pour éviter includes manquants)
  442. En production, extraire dans _partials/
  443. ================================================================ #}
  444. {% macro renderResultCard(result, isPremium) %}
  445. {% set typeColors = {
  446. 'loi': 'bg-purple-100 text-purple-700 border-purple-200',
  447. 'decret': 'bg-blue-100 text-blue-700 border-blue-200',
  448. 'arrete': 'bg-green-100 text-green-700 border-green-200',
  449. 'circulaire': 'bg-cyan-100 text-cyan-700 border-cyan-200',
  450. 'avis': 'bg-sky-100 text-sky-700 border-sky-200',
  451. 'marche_public':'bg-emerald-100 text-emerald-700 border-emerald-200',
  452. 'acte_foncier': 'bg-teal-100 text-teal-700 border-teal-200',
  453. 'societe_rccm': 'bg-indigo-100 text-indigo-700 border-indigo-200',
  454. 'association': 'bg-amber-100 text-amber-700 border-amber-200',
  455. 'enchere': 'bg-orange-100 text-orange-700 border-orange-200'
  456. } %}
  457. {% set typeIcons = {
  458. 'loi': 'fa-gavel', 'decret': 'fa-file-signature',
  459. 'arrete': 'fa-clipboard-list', 'circulaire': 'fa-envelope',
  460. 'avis': 'fa-bell', 'marche_public': 'fa-briefcase',
  461. 'acte_foncier': 'fa-map-marker-alt', 'societe_rccm': 'fa-building',
  462. 'association': 'fa-handshake', 'enchere': 'fa-hammer'
  463. } %}
  464. <article class="bg-white border border-gray-100 rounded-2xl shadow-sm hover:shadow-md transition-all duration-200 p-5 group">
  465. <div class="flex flex-wrap justify-between items-start gap-2 mb-3">
  466. <div class="flex flex-wrap items-center gap-2">
  467. <span class="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-bold rounded-full border {{ typeColors[result.type]|default('bg-gray-100 text-gray-700 border-gray-200') }}">
  468. <i class="fas {{ typeIcons[result.type]|default('fa-file-alt') }} text-[10px]"></i>
  469. {{ result.typeLabel|default(result.type|upper) }}
  470. </span>
  471. {% if result.numero is defined and result.numero %}
  472. <span class="text-xs text-gray-400 font-mono bg-gray-50 px-2 py-0.5 rounded">{{ result.numero }}</span>
  473. {% endif %}
  474. {% if result.statutJuridique is defined %}
  475. <span class="text-xs px-2 py-0.5 rounded
  476. {% if result.statutJuridique == 'en_vigueur' %}bg-green-50 text-green-600
  477. {% elseif result.statutJuridique == 'abroge' %}bg-red-50 text-red-600
  478. {% else %}bg-gray-50 text-gray-500{% endif %}">
  479. {{ result.statutJuridique|replace({'_': ' '})|title }}
  480. </span>
  481. {% endif %}
  482. </div>
  483. <time class="text-xs text-gray-400 flex items-center gap-1 flex-shrink-0">
  484. <i class="far fa-calendar-alt"></i>
  485. {{ result.datePublication|date('d/m/Y') }}
  486. </time>
  487. </div>
  488. <h3 class="text-base font-bold text-gray-800 mb-2 group-hover:text-[#006633] transition leading-snug">
  489. <a href="{{ path('app_acte_show', {id: result.id}) }}">{{ result.titre }}</a>
  490. </h3>
  491. {% if result.resume is defined and result.resume %}
  492. <p class="text-gray-500 text-sm mb-3 line-clamp-2">{{ result.resume }}</p>
  493. {% endif %}
  494. {% if isPremium and result.extrait is defined and result.extrait %}
  495. <div class="mb-3 px-3 py-2 bg-amber-50 border-l-3 border-amber-400 rounded-r-lg text-xs text-gray-700">
  496. <i class="fas fa-quote-left text-amber-400 mr-1"></i>
  497. <span>{{ result.extrait }}</span>
  498. </div>
  499. {% endif %}
  500. <div class="flex flex-wrap items-center justify-between gap-2 pt-3 border-t border-gray-50">
  501. <div class="flex items-center gap-3 text-xs text-gray-500">
  502. {% if result.institution is defined %}
  503. <span><i class="fas fa-landmark text-[#006633] mr-1"></i>{{ result.institution }}</span>
  504. {% endif %}
  505. {% if result.territoire is defined %}
  506. <span><i class="fas fa-map-marker-alt text-gray-400 mr-1"></i>{{ result.territoire }}</span>
  507. {% endif %}
  508. {% if isPremium and result.relevance is defined %}
  509. <span class="text-[#006633] font-semibold">
  510. <i class="fas fa-chart-line mr-1"></i>{{ (result.relevance * 100)|round }}% pertinent
  511. </span>
  512. {% endif %}
  513. </div>
  514. <div class="flex items-center gap-3">
  515. <a href="{{ path('app_acte_show', {id: result.id}) }}"
  516. class="text-xs text-[#006633] hover:text-[#008040] font-semibold flex items-center gap-1 transition">
  517. <i class="fas fa-eye"></i> Consulter
  518. </a>
  519. {% if result.pdf_url is defined and result.pdf_url %}
  520. <a href="{{ result.pdf_url }}" target="_blank" rel="noopener"
  521. class="text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1 transition">
  522. <i class="fas fa-file-pdf"></i> PDF
  523. </a>
  524. {% endif %}
  525. </div>
  526. </div>
  527. </article>
  528. {% endmacro %}
  529. <!-- ============================================================
  530. JAVASCRIPT DYNAMIQUE
  531. ============================================================ -->
  532. <script>
  533. (function () {
  534. 'use strict';
  535. // ─── Config ───────────────────────────────────────────────────────────────
  536. const SEARCH_URL = '{{ path("app_search") }}';
  537. const SEARCH_API_URL = '{{ path("app_search_ajax") }}'; // endpoint JSON
  538. const DEBOUNCE_MS = 400;
  539. const MIN_CHARS = 2;
  540. // ─── Éléments DOM ─────────────────────────────────────────────────────────
  541. const mainInput = document.getElementById('main-search-input');
  542. const heroBtnSearch = document.getElementById('hero-search-btn');
  543. const autocompleteBox = document.getElementById('autocomplete-dropdown');
  544. const resultsContainer = document.getElementById('results-container');
  545. const paginationCont = document.getElementById('pagination-container');
  546. const statusBar = document.getElementById('results-status-bar');
  547. const countNumber = document.getElementById('count-number');
  548. const countLabel = document.getElementById('results-count-label');
  549. const spinner = document.getElementById('search-spinner');
  550. const filterForm = document.getElementById('advanced-search-form');
  551. const filterSelects = filterForm.querySelectorAll('.search-filter');
  552. const resetBtn = document.getElementById('reset-filters-btn');
  553. const applyBtn = document.getElementById('apply-filters-btn');
  554. const activeCountBadge = document.getElementById('active-filters-count');
  555. const filterChips = document.getElementById('active-filter-chips');
  556. const toggleFiltersBtn = document.getElementById('toggle-filters-btn');
  557. const toggleFiltersIcon= document.getElementById('toggle-filters-icon');
  558. const toggleFiltersLbl = document.getElementById('toggle-filters-label');
  559. const filtersPanel = document.getElementById('filters-panel');
  560. const periodSelect = document.getElementById('filter-period');
  561. const customDatesPanel = document.getElementById('custom-dates-panel');
  562. const quickTypeBtns = document.querySelectorAll('.quick-type-btn');
  563. const quickSuggestions = document.querySelectorAll('.quick-suggestion');
  564. // ─── État ─────────────────────────────────────────────────────────────────
  565. let debounceTimer = null;
  566. let currentPage = 1;
  567. let filtersVisible = true;
  568. let lastQuery = mainInput ? mainInput.value.trim() : '';
  569. let currentParams = {};
  570. // ─── Noms lisibles des filtres pour les chips ─────────────────────────────
  571. const filterLabels = {
  572. institution: {
  573. presidence: 'Présidence de l\'Union',
  574. assemblee_nationale: 'Assemblée Nationale',
  575. sgg: 'SGG',
  576. finances: 'Min. Finances',
  577. justice: 'Min. Justice',
  578. interieur: 'Min. Intérieur',
  579. sante: 'Min. Santé',
  580. education: 'Min. Éducation',
  581. gouvernorat_ngazidja: 'Gouvernorat Ngazidja',
  582. gouvernorat_anjouan: 'Gouvernorat Anjouan',
  583. gouvernorat_moheli: 'Gouvernorat Mohéli',
  584. vice_presidence: 'Vice-Présidence',
  585. transport: 'Min. Transport',
  586. agriculture: 'Min. Agriculture',
  587. travaux_publics: 'Min. Travaux Publics',
  588. autres_ministeres: 'Autres ministères',
  589. justice_cour_supreme: 'Cour Suprême',
  590. justice_cour_appel: 'Cour d\'Appel',
  591. },
  592. type: {
  593. loi: 'Lois', decret: 'Décrets', arrete: 'Arrêtés',
  594. circulaire: 'Circulaires', avis: 'Avis officiels',
  595. marche_public: 'Marchés publics & DSP',
  596. acte_foncier: 'Réquisitions & actes fonciers',
  597. societe_rccm: 'Actes RCCM/OHADA',
  598. enchere: 'Ventes aux enchères',
  599. association: 'Récépissés associations',
  600. },
  601. statut: {
  602. en_vigueur: 'En vigueur', modifie: 'Modifié',
  603. abroge: 'Abrogé', suspendu: 'Suspendu',
  604. remplace: 'Remplacé', archive: 'Archivé',
  605. },
  606. territoire: {
  607. union: 'Union des Comores', ngazidja: 'Grande Comore',
  608. anjouan: 'Anjouan', moheli: 'Mohéli',
  609. moroni: 'Moroni', mutsamudu: 'Mutsamudu', fomboni: 'Fomboni',
  610. },
  611. sort: {
  612. date_desc: 'Récent → Ancien', date_asc: 'Ancien → Récent',
  613. pertinence: 'Pertinence', titre_asc: 'Titre A→Z',
  614. },
  615. };
  616. // ─── Utilitaires ──────────────────────────────────────────────────────────
  617. function debounce(fn, delay) {
  618. return function (...args) {
  619. clearTimeout(debounceTimer);
  620. debounceTimer = setTimeout(() => fn.apply(this, args), delay);
  621. };
  622. }
  623. function getFormParams() {
  624. const params = {};
  625. if (mainInput && mainInput.value.trim()) params.q = mainInput.value.trim();
  626. filterSelects.forEach(el => {
  627. if (el.value) params[el.name] = el.value;
  628. });
  629. return params;
  630. }
  631. function buildQueryString(params) {
  632. return Object.entries(params)
  633. .filter(([, v]) => v)
  634. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  635. .join('&');
  636. }
  637. // ─── Chips filtres actifs ──────────────────────────────────────────────────
  638. function updateFilterChips() {
  639. if (!filterChips) return;
  640. filterChips.innerHTML = '';
  641. let count = 0;
  642. filterSelects.forEach(el => {
  643. if (!el.value || el.name === 'sort') return;
  644. count++;
  645. const label = (filterLabels[el.name] || {})[el.value] || el.value;
  646. const chip = document.createElement('span');
  647. chip.className = 'inline-flex items-center gap-1.5 bg-[#006633]/10 text-[#006633] border border-[#006633]/20 text-xs font-semibold px-3 py-1 rounded-full';
  648. chip.innerHTML = `${label} <button type="button" data-filter="${el.name}" class="remove-chip ml-0.5 hover:text-red-500 transition"><i class="fas fa-times text-[10px]"></i></button>`;
  649. filterChips.appendChild(chip);
  650. });
  651. // Badge compteur
  652. if (count > 0) {
  653. activeCountBadge.textContent = count;
  654. activeCountBadge.classList.remove('hidden');
  655. resetBtn.classList.remove('hidden');
  656. } else {
  657. activeCountBadge.classList.add('hidden');
  658. resetBtn.classList.add('hidden');
  659. }
  660. // Clics sur remove-chip
  661. filterChips.querySelectorAll('.remove-chip').forEach(btn => {
  662. btn.addEventListener('click', () => {
  663. const filterName = btn.dataset.filter;
  664. const el = filterForm.querySelector(`[name="${filterName}"]`);
  665. if (el) { el.value = ''; }
  666. updateFilterChips();
  667. triggerSearch(1);
  668. });
  669. });
  670. }
  671. // ─── Toggle panel filtres ─────────────────────────────────────────────────
  672. if (toggleFiltersBtn) {
  673. toggleFiltersBtn.addEventListener('click', () => {
  674. filtersVisible = !filtersVisible;
  675. filtersPanel.style.maxHeight = filtersVisible ? filtersPanel.scrollHeight + 'px' : '0';
  676. filtersPanel.style.overflow = filtersVisible ? 'visible' : 'hidden';
  677. toggleFiltersIcon.style.transform = filtersVisible ? 'rotate(0deg)' : 'rotate(-90deg)';
  678. toggleFiltersLbl.textContent = filtersVisible ? 'Masquer les filtres' : 'Afficher les filtres';
  679. });
  680. }
  681. // ─── Période personnalisée ────────────────────────────────────────────────
  682. if (periodSelect) {
  683. periodSelect.addEventListener('change', function () {
  684. customDatesPanel.classList.toggle('hidden', this.value !== 'custom');
  685. });
  686. }
  687. // ─── Boutons filtres rapides (Hero) ───────────────────────────────────────
  688. quickTypeBtns.forEach(btn => {
  689. btn.addEventListener('click', () => {
  690. // Update visuel
  691. quickTypeBtns.forEach(b => {
  692. b.className = b.className.replace('bg-white text-[#006633] shadow-lg', 'bg-white/20 text-white hover:bg-white/30 border border-white/20');
  693. });
  694. btn.className = btn.className.replace('bg-white/20 text-white hover:bg-white/30 border border-white/20', 'bg-white text-[#006633] shadow-lg');
  695. // Synchro avec le select type
  696. const typeFilter = document.getElementById('filter-type');
  697. if (typeFilter) typeFilter.value = btn.dataset.type;
  698. updateFilterChips();
  699. triggerSearch(1);
  700. });
  701. });
  702. // ─── Suggestions rapides ──────────────────────────────────────────────────
  703. quickSuggestions.forEach(btn => {
  704. btn.addEventListener('click', () => {
  705. if (mainInput) mainInput.value = btn.textContent.trim();
  706. triggerSearch(1);
  707. });
  708. });
  709. // ─── Autocomplete ─────────────────────────────────────────────────────────
  710. const debouncedAutocomplete = debounce(fetchAutocomplete, 300);
  711. if (mainInput) {
  712. mainInput.addEventListener('input', function () {
  713. const q = this.value.trim();
  714. if (q.length >= MIN_CHARS) {
  715. debouncedAutocomplete(q);
  716. } else {
  717. hideAutocomplete();
  718. }
  719. // Live search
  720. debouncedSearch();
  721. });
  722. mainInput.addEventListener('keydown', function (e) {
  723. if (e.key === 'Enter') {
  724. e.preventDefault();
  725. hideAutocomplete();
  726. triggerSearch(1);
  727. }
  728. if (e.key === 'Escape') hideAutocomplete();
  729. });
  730. }
  731. if (heroBtnSearch) {
  732. heroBtnSearch.addEventListener('click', () => {
  733. hideAutocomplete();
  734. triggerSearch(1);
  735. });
  736. }
  737. function hideAutocomplete() {
  738. if (autocompleteBox) autocompleteBox.classList.add('hidden');
  739. }
  740. async function fetchAutocomplete(q) {
  741. try {
  742. const res = await fetch(`${SEARCH_API_URL}?q=${encodeURIComponent(q)}&autocomplete=1&limit=6`);
  743. if (!res.ok) return;
  744. const data = await res.json();
  745. renderAutocomplete(data.suggestions || []);
  746. } catch (e) {
  747. // silencieux
  748. }
  749. }
  750. function renderAutocomplete(suggestions) {
  751. if (!autocompleteBox || suggestions.length === 0) {
  752. hideAutocomplete();
  753. return;
  754. }
  755. autocompleteBox.innerHTML = suggestions.map(s => `
  756. <button type="button" class="autocomplete-item w-full text-left px-4 py-3 hover:bg-emerald-50 flex items-center gap-3 border-b border-gray-50 last:border-none transition-colors">
  757. <i class="fas fa-search text-gray-300 text-xs flex-shrink-0"></i>
  758. <span class="text-sm text-gray-700">${escapeHtml(s.titre || s)}</span>
  759. ${s.type ? `<span class="ml-auto text-xs text-gray-400 font-mono">${escapeHtml(s.type)}</span>` : ''}
  760. </button>
  761. `).join('');
  762. autocompleteBox.classList.remove('hidden');
  763. autocompleteBox.querySelectorAll('.autocomplete-item').forEach((item, idx) => {
  764. item.addEventListener('click', () => {
  765. const s = suggestions[idx];
  766. if (mainInput) mainInput.value = s.titre || s;
  767. hideAutocomplete();
  768. triggerSearch(1);
  769. });
  770. });
  771. }
  772. document.addEventListener('click', e => {
  773. if (!autocompleteBox?.contains(e.target) && e.target !== mainInput) hideAutocomplete();
  774. });
  775. // ─── Changement de filtre → rechargement live ─────────────────────────────
  776. filterSelects.forEach(el => {
  777. el.addEventListener('change', () => {
  778. updateFilterChips();
  779. if (el.name !== 'date_debut' && el.name !== 'date_fin') triggerSearch(1);
  780. });
  781. });
  782. // Dates personnalisées: déclencher à la perte de focus
  783. ['filter-date-debut', 'filter-date-fin'].forEach(id => {
  784. const el = document.getElementById(id);
  785. if (el) el.addEventListener('change', () => triggerSearch(1));
  786. });
  787. // ─── Formulaire submit ────────────────────────────────────────────────────
  788. if (filterForm) {
  789. filterForm.addEventListener('submit', e => {
  790. e.preventDefault();
  791. triggerSearch(1);
  792. });
  793. }
  794. // ─── Recherche principale (AJAX) ──────────────────────────────────────────
  795. const debouncedSearch = debounce(() => triggerSearch(1), DEBOUNCE_MS);
  796. async function triggerSearch(page = 1) {
  797. currentPage = page;
  798. const params = getFormParams();
  799. params.page = page;
  800. currentParams = { ...params };
  801. // Mettre à jour l'URL sans rechargement
  802. const qs = buildQueryString(params);
  803. window.history.replaceState({}, '', `${SEARCH_URL}${qs ? '?' + qs : ''}`);
  804. if (!params.q && Object.keys(params).filter(k => k !== 'sort' && k !== 'page').length === 0) {
  805. // Aucun critère: afficher état accueil
  806. showWelcomeState();
  807. return;
  808. }
  809. showLoading();
  810. try {
  811. const res = await fetch(`${SEARCH_API_URL}?${qs}`, {
  812. headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
  813. });
  814. // Si la route API n'existe pas encore (404/405/500), fallback SSR silencieux
  815. if (res.status === 404 || res.status === 405) {
  816. window.location.href = `${SEARCH_URL}?${qs}`;
  817. return;
  818. }
  819. if (!res.ok) throw new Error('HTTP ' + res.status);
  820. const data = await res.json();
  821. renderResults(data, params.q || '');
  822. } catch (err) {
  823. // Erreur réseau : fallback vers SSR plutôt qu'afficher un message d'erreur
  824. console.warn('[Munganyo] API indisponible, fallback SSR:', err.message);
  825. window.location.href = `${SEARCH_URL}?${qs}`;
  826. }
  827. }
  828. function showLoading() {
  829. if (spinner) spinner.classList.remove('hidden');
  830. if (statusBar) statusBar.classList.remove('hidden');
  831. if (resultsContainer) {
  832. resultsContainer.style.opacity = '0.4';
  833. resultsContainer.style.pointerEvents = 'none';
  834. }
  835. }
  836. function hideLoading() {
  837. if (spinner) spinner.classList.add('hidden');
  838. if (resultsContainer) {
  839. resultsContainer.style.opacity = '1';
  840. resultsContainer.style.pointerEvents = '';
  841. }
  842. }
  843. function showWelcomeState() {
  844. hideLoading();
  845. if (statusBar) statusBar.classList.add('hidden');
  846. if (resultsContainer) {
  847. resultsContainer.innerHTML = document.getElementById('welcome-state')
  848. ? document.getElementById('welcome-state').outerHTML
  849. : `<div class="text-center py-16 text-gray-500"><i class="fas fa-search text-4xl mb-4 text-gray-300"></i><p>Effectuez une recherche ci-dessus</p></div>`;
  850. }
  851. if (paginationCont) paginationCont.innerHTML = '';
  852. }
  853. function showErrorState() {
  854. hideLoading();
  855. if (statusBar) statusBar.classList.add('hidden');
  856. if (paginationCont) paginationCont.innerHTML = '';
  857. const q = mainInput ? mainInput.value.trim() : '';
  858. if (resultsContainer) {
  859. resultsContainer.innerHTML = `
  860. <div class="text-center py-16 bg-orange-50 rounded-2xl border border-orange-100">
  861. <div class="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-5">
  862. <i class="fas fa-wifi text-2xl text-orange-400"></i>
  863. </div>
  864. <h3 class="text-lg font-semibold text-orange-700 mb-2">Connexion temporairement indisponible</h3>
  865. <p class="text-sm text-gray-500 max-w-sm mx-auto mb-5">
  866. La recherche en temps réel est momentanément inaccessible.
  867. Vous pouvez relancer manuellement votre recherche.
  868. </p>
  869. <a href="${SEARCH_URL}${q ? '?q=' + encodeURIComponent(q) : ''}"
  870. class="inline-flex items-center gap-2 bg-[#006633] text-white px-6 py-2.5 rounded-xl text-sm font-semibold hover:bg-[#004d26] transition shadow-md">
  871. <i class="fas fa-redo-alt"></i> Relancer la recherche
  872. </a>
  873. </div>`;
  874. }
  875. }
  876. function renderResults(data, query) {
  877. hideLoading();
  878. // Mise à jour compteur
  879. if (statusBar) statusBar.classList.remove('hidden');
  880. if (countLabel) {
  881. const q = query ? `pour <em class="text-[#006633] not-italic font-black">"${escapeHtml(query)}"</em>` : '';
  882. countLabel.innerHTML = `<span id="count-number">${data.total || 0}</span> résultat(s) ${q}`;
  883. }
  884. // Résultats
  885. if (!resultsContainer) return;
  886. if (!data.results || data.results.length === 0) {
  887. resultsContainer.innerHTML = renderNoResults(query);
  888. } else {
  889. resultsContainer.innerHTML = `<div class="space-y-4">${data.results.map(r => renderResultCard(r, data.isPremium)).join('')}</div>`;
  890. }
  891. // Pagination
  892. if (paginationCont) {
  893. paginationCont.innerHTML = data.totalPages > 1
  894. ? renderPagination(data.currentPage, data.totalPages, currentParams)
  895. : '';
  896. }
  897. // Scroll doux vers résultats
  898. document.getElementById('results-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
  899. }
  900. // ─── Rendu côté client (fallback / AJAX) ─────────────────────────────────
  901. const TYPE_COLORS = {
  902. loi: 'bg-purple-100 text-purple-700 border-purple-200',
  903. decret: 'bg-blue-100 text-blue-700 border-blue-200',
  904. arrete: 'bg-green-100 text-green-700 border-green-200',
  905. circulaire: 'bg-cyan-100 text-cyan-700 border-cyan-200',
  906. avis: 'bg-sky-100 text-sky-700 border-sky-200',
  907. marche_public: 'bg-emerald-100 text-emerald-700 border-emerald-200',
  908. acte_foncier: 'bg-teal-100 text-teal-700 border-teal-200',
  909. societe_rccm: 'bg-indigo-100 text-indigo-700 border-indigo-200',
  910. association: 'bg-amber-100 text-amber-700 border-amber-200',
  911. enchere: 'bg-orange-100 text-orange-700 border-orange-200',
  912. };
  913. const TYPE_ICONS = {
  914. loi: 'fa-gavel', decret: 'fa-file-signature', arrete: 'fa-clipboard-list',
  915. circulaire: 'fa-envelope', avis: 'fa-bell', marche_public: 'fa-briefcase',
  916. acte_foncier: 'fa-map-marker-alt', societe_rccm: 'fa-building',
  917. association: 'fa-handshake', enchere: 'fa-hammer',
  918. };
  919. const STATUT_COLORS = {
  920. en_vigueur: 'bg-green-50 text-green-600',
  921. abroge: 'bg-red-50 text-red-600',
  922. modifie: 'bg-yellow-50 text-yellow-700',
  923. suspendu: 'bg-orange-50 text-orange-600',
  924. };
  925. function renderResultCard(r, isPremium) {
  926. const colorClass = TYPE_COLORS[r.type] || 'bg-gray-100 text-gray-700 border-gray-200';
  927. const iconClass = TYPE_ICONS[r.type] || 'fa-file-alt';
  928. const statutClr = STATUT_COLORS[r.statutJuridique] || 'bg-gray-50 text-gray-500';
  929. const date = r.datePublication ? new Date(r.datePublication).toLocaleDateString('fr-FR') : 'N/A';
  930. return `
  931. <article class="bg-white border border-gray-100 rounded-2xl shadow-sm hover:shadow-md transition-all duration-200 p-5 group">
  932. <div class="flex flex-wrap justify-between items-start gap-2 mb-3">
  933. <div class="flex flex-wrap items-center gap-2">
  934. <span class="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-bold rounded-full border ${colorClass}">
  935. <i class="fas ${iconClass} text-[10px]"></i> ${escapeHtml(r.typeLabel || r.type)}
  936. </span>
  937. ${r.numero ? `<span class="text-xs text-gray-400 font-mono bg-gray-50 px-2 py-0.5 rounded">${escapeHtml(r.numero)}</span>` : ''}
  938. ${r.statutJuridique ? `<span class="text-xs px-2 py-0.5 rounded ${statutClr}">${escapeHtml(r.statutJuridique.replace('_', ' '))}</span>` : ''}
  939. </div>
  940. <time class="text-xs text-gray-400 flex items-center gap-1 flex-shrink-0">
  941. <i class="far fa-calendar-alt"></i> ${date}
  942. </time>
  943. </div>
  944. <h3 class="text-base font-bold text-gray-800 mb-2 group-hover:text-[#006633] transition leading-snug">
  945. <a href="/acte/${r.id}">${escapeHtml(r.titre)}</a>
  946. </h3>
  947. ${r.resume ? `<p class="text-gray-500 text-sm mb-3 line-clamp-2">${escapeHtml(r.resume)}</p>` : ''}
  948. ${isPremium && r.extrait ? `
  949. <div class="mb-3 px-3 py-2 bg-amber-50 border-l-4 border-amber-400 rounded-r-lg text-xs text-gray-700">
  950. <i class="fas fa-quote-left text-amber-400 mr-1"></i>${escapeHtml(r.extrait)}
  951. </div>` : ''}
  952. <div class="flex flex-wrap items-center justify-between gap-2 pt-3 border-t border-gray-50">
  953. <div class="flex items-center gap-3 text-xs text-gray-500">
  954. ${r.institution ? `<span><i class="fas fa-landmark text-[#006633] mr-1"></i>${escapeHtml(r.institution)}</span>` : ''}
  955. ${r.territoire ? `<span><i class="fas fa-map-marker-alt text-gray-400 mr-1"></i>${escapeHtml(r.territoire)}</span>` : ''}
  956. ${isPremium && r.relevance ? `<span class="text-[#006633] font-semibold"><i class="fas fa-chart-line mr-1"></i>${Math.round(r.relevance * 100)}%</span>` : ''}
  957. </div>
  958. <div class="flex items-center gap-3">
  959. <a href="/acte/${r.id}" class="text-xs text-[#006633] hover:text-[#008040] font-semibold flex items-center gap-1 transition">
  960. <i class="fas fa-eye"></i> Consulter
  961. </a>
  962. ${r.pdf_url ? `<a href="${escapeHtml(r.pdf_url)}" target="_blank" rel="noopener" class="text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1 transition"><i class="fas fa-file-pdf"></i> PDF</a>` : ''}
  963. </div>
  964. </div>
  965. </article>`;
  966. }
  967. function renderNoResults(query) {
  968. const activeFilters = [];
  969. const typeEl = document.getElementById('filter-type');
  970. const instEl = document.getElementById('filter-institution');
  971. const periodEl = document.getElementById('filter-period');
  972. if (typeEl && typeEl.options[typeEl.selectedIndex]?.text && typeEl.value)
  973. activeFilters.push(typeEl.options[typeEl.selectedIndex].text);
  974. if (instEl && instEl.options[instEl.selectedIndex]?.text && instEl.value)
  975. activeFilters.push(instEl.options[instEl.selectedIndex].text);
  976. if (periodEl && periodEl.value && periodEl.value !== 'custom')
  977. activeFilters.push('année ' + periodEl.value);
  978. let searchDesc = '';
  979. if (query) searchDesc += `<strong class="text-gray-800">&laquo;&nbsp;${escapeHtml(query)}&nbsp;&raquo;</strong>`;
  980. if (activeFilters.length) {
  981. searchDesc += (query ? ' avec les filtres ' : 'les critères ');
  982. searchDesc += activeFilters.map(f => `<span class="inline-flex items-center gap-1 bg-[#006633]/10 text-[#006633] text-xs font-semibold px-2 py-0.5 rounded-full">${escapeHtml(f)}</span>`).join(' ');
  983. }
  984. if (!searchDesc) searchDesc = 'ces critères de recherche';
  985. return `
  986. <div class="text-center py-16 bg-gray-50 rounded-2xl border border-gray-100">
  987. <div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-5">
  988. <i class="fas fa-file-search text-2xl text-gray-400"></i>
  989. </div>
  990. <h3 class="text-xl font-bold text-gray-800 mb-3">
  991. Aucun acte trouvé pour ${searchDesc}
  992. </h3>
  993. <p class="text-gray-500 text-sm max-w-md mx-auto mb-6 leading-relaxed">
  994. Votre recherche ${query ? `avec <strong class="text-gray-700">&laquo;&nbsp;${escapeHtml(query)}&nbsp;&raquo;</strong>` : ''} ne correspond
  995. à aucun acte publié au Journal Officiel de l'Union des Comores.
  996. </p>
  997. <div class="flex flex-wrap justify-center gap-3 text-xs text-gray-500 mb-6">
  998. <span class="flex items-center gap-1.5 bg-white border border-gray-200 px-3 py-1.5 rounded-full shadow-sm">
  999. <i class="fas fa-lightbulb text-amber-400"></i> Vérifiez l'orthographe des mots-clés
  1000. </span>
  1001. <span class="flex items-center gap-1.5 bg-white border border-gray-200 px-3 py-1.5 rounded-full shadow-sm">
  1002. <i class="fas fa-expand-alt text-blue-400"></i> Élargissez ou supprimez les filtres
  1003. </span>
  1004. <span class="flex items-center gap-1.5 bg-white border border-gray-200 px-3 py-1.5 rounded-full shadow-sm">
  1005. <i class="fas fa-hashtag text-[#006633]"></i> Essayez la référence exacte de l'acte
  1006. </span>
  1007. </div>
  1008. <div class="flex flex-wrap justify-center gap-3">
  1009. <button type="button" onclick="document.getElementById('main-search-input').value='';document.getElementById('filter-type').value='';document.getElementById('filter-institution').value='';triggerSearch(1);"
  1010. class="inline-flex items-center gap-2 border border-gray-200 text-gray-600 px-5 py-2 rounded-xl text-sm font-medium hover:border-gray-300 hover:bg-gray-50 transition">
  1011. <i class="fas fa-undo-alt text-xs"></i> Réinitialiser la recherche
  1012. </button>
  1013. <a href="/abonnement"
  1014. class="inline-flex items-center gap-2 bg-gradient-to-r from-amber-500 to-amber-400 text-white px-5 py-2 rounded-xl text-sm font-semibold hover:from-amber-600 hover:to-amber-500 transition shadow-md">
  1015. <i class="fas fa-microchip text-xs"></i> Activer Ibunas.IA Premium
  1016. </a>
  1017. </div>
  1018. </div>`;
  1019. }
  1020. function renderPagination(current, total, params) {
  1021. let pages = '';
  1022. for (let p = 1; p <= total; p++) {
  1023. if (p === current) {
  1024. pages += `<li><span class="flex items-center justify-center w-9 h-9 rounded-lg bg-[#006633] text-white font-bold text-sm">${p}</span></li>`;
  1025. } else if (p <= 3 || p >= total - 2 || Math.abs(p - current) <= 2) {
  1026. const ps = buildQueryString({ ...params, page: p });
  1027. pages += `<li><button type="button" data-page="${p}" class="pagination-btn flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-600 hover:border-[#006633] hover:text-[#006633] transition text-sm">${p}</button></li>`;
  1028. } else if (p === 4 && current > 6) {
  1029. pages += `<li><span class="px-2 text-gray-400">…</span></li>`;
  1030. } else if (p === total - 3 && current < total - 5) {
  1031. pages += `<li><span class="px-2 text-gray-400">…</span></li>`;
  1032. }
  1033. }
  1034. const prevDisabled = current <= 1 ? 'opacity-40 pointer-events-none' : '';
  1035. const nextDisabled = current >= total ? 'opacity-40 pointer-events-none' : '';
  1036. return `
  1037. <nav class="flex justify-center mt-8" aria-label="Pagination">
  1038. <ul class="flex items-center gap-1">
  1039. <li><button type="button" data-page="${current - 1}" class="pagination-btn flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-500 hover:border-[#006633] hover:text-[#006633] transition text-sm ${prevDisabled}"><i class="fas fa-chevron-left text-xs"></i></button></li>
  1040. ${pages}
  1041. <li><button type="button" data-page="${current + 1}" class="pagination-btn flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-500 hover:border-[#006633] hover:text-[#006633] transition text-sm ${nextDisabled}"><i class="fas fa-chevron-right text-xs"></i></button></li>
  1042. </ul>
  1043. </nav>`;
  1044. }
  1045. // Délégation pour les boutons de pagination générés dynamiquement
  1046. document.addEventListener('click', e => {
  1047. const btn = e.target.closest('.pagination-btn');
  1048. if (btn) {
  1049. const page = parseInt(btn.dataset.page);
  1050. if (page && page > 0) triggerSearch(page);
  1051. }
  1052. });
  1053. // ─── XSS helper ───────────────────────────────────────────────────────────
  1054. function escapeHtml(str) {
  1055. if (!str) return '';
  1056. return String(str)
  1057. .replace(/&/g, '&amp;')
  1058. .replace(/</g, '&lt;')
  1059. .replace(/>/g, '&gt;')
  1060. .replace(/"/g, '&quot;')
  1061. .replace(/'/g, '&#39;');
  1062. }
  1063. // ─── Init ─────────────────────────────────────────────────────────────────
  1064. function init() {
  1065. updateFilterChips();
  1066. // Si query présente dans l'URL au chargement, on l'affiche sans re-fetch (SSR)
  1067. // Si via navigation arrière/avant, on re-fetch
  1068. window.addEventListener('popstate', () => {
  1069. const urlParams = new URLSearchParams(window.location.search);
  1070. if (mainInput) mainInput.value = urlParams.get('q') || '';
  1071. filterSelects.forEach(el => {
  1072. el.value = urlParams.get(el.name) || '';
  1073. });
  1074. updateFilterChips();
  1075. const q = urlParams.get('q');
  1076. if (q) triggerSearch(parseInt(urlParams.get('page') || '1'));
  1077. });
  1078. }
  1079. init();
  1080. })();
  1081. </script>
  1082. {% endblock %}