templates/application/whileresume/application/enterprises/dashboard.html.twig line 1

Open in your IDE?
  1. {% extends 'application/whileresume/application/enterprises/layout-social.html.twig' %}
  2. {% trans_default_domain 'whr-public' %}
  3. {% block title %}{% if currentSubKeyword is defined and currentSubKeyword is not null and currentCityFilter is defined and currentCityFilter is not null %}{{ currentSubKeyword }} {{ currentCityFilter.label }}{% elseif currentFilter is defined and currentFilter is not null %}{{ currentFilter.shortTitle|default(currentFilter.label) }} — WhileResume{% else %}{{ 'company.dashboard.title'|trans }}{% endif %}{% endblock title %}
  4. {% block description %}{% if currentSubKeyword is defined and currentSubKeyword is not null and currentCityFilter is defined and currentCityFilter is not null %}Découvrez les entreprises "{{ currentSubKeyword }}" à {{ currentCityFilter.label }}.{% elseif currentFilter is defined and currentFilter is not null and currentFilter.shortDescription is not empty %}{{ currentFilter.shortDescription }}{% else %}{{ 'company.dashboard.description'|trans }}{% endif %}{% endblock description %}
  5. {% block robots %}index,follow{% endblock robots %}
  6. {% block css %}
  7. <style>
  8. /* ─── Page dashboard /jobs ─── */
  9. .jobs-dash{max-width:880px;margin:0 auto}
  10. .jobs-dash-header{margin-bottom:18px}
  11. .jobs-dash-title{font-size:24px;font-weight:800;color:#1E1B2E;line-height:1.2;letter-spacing:-0.02em;margin:0 0 6px}
  12. @media(min-width:768px){.jobs-dash-title{font-size:28px}}
  13. .jobs-dash-subtitle{font-size:14px;color:#6B7280;margin:0 0 18px}
  14. /* ─── Bandeau contexte ville (badge + bouton ALL) ─── */
  15. .city-context-bar{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:0 0 16px;padding:10px 14px;background:#F5F3FF;border:1px solid rgba(108,58,237,.15);border-radius:12px}
  16. .city-context-label{font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:.06em}
  17. .city-context-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:100px;background:#fff;color:var(--theme-color,#6C3AED);font-size:13px;font-weight:600;border:1px solid rgba(108,58,237,.2)}
  18. .city-context-badge-icon{font-size:14px;line-height:1}
  19. .city-context-clear-btn{margin-left:auto;display:inline-flex;align-items:center;gap:4px;padding:5px 12px;border-radius:100px;background:transparent;color:var(--theme-color,#6C3AED);font-size:12px;font-weight:600;border:1px dashed rgba(108,58,237,.4);cursor:pointer;text-decoration:none;font-family:inherit;transition:background .15s,border-style .15s,border-color .15s}
  20. .city-context-clear-btn:hover{background:#fff;border-style:solid;border-color:var(--theme-color,#6C3AED);color:var(--theme-color,#6C3AED)}
  21. .city-context-clear-btn svg{width:11px;height:11px}
  22. /* Widget recherche */
  23. .wr-search-widget{background:#fff;border-radius:14px;box-shadow:0 0 20px 0 rgba(0,0,0,0.05);padding:10px;margin-bottom:14px;position:relative;z-index:50}
  24. .wr-search-input-wrap{display:flex;align-items:center;gap:10px;padding:8px 14px;border-radius:10px;background:#F9FAFB;border:1px solid #E5E7EB;transition:border-color .2s,background .2s}
  25. .wr-search-input-wrap:focus-within{border-color:rgba(108,58,237,.3);background:#fff}
  26. .wr-search-icon{color:#9CA3AF;display:flex;flex-shrink:0}
  27. .wr-search-input{flex:1;border:none;outline:none;background:transparent;font-size:14px;color:#1E1B2E;padding:4px 0;font-family:inherit;min-width:0}
  28. .wr-search-input::placeholder{color:#9CA3AF}
  29. .wr-search-clear{background:none;border:none;color:#9CA3AF;cursor:pointer;padding:4px;display:none;font-family:inherit}
  30. .wr-search-clear.visible{display:flex;align-items:center}
  31. .wr-search-clear:hover{color:#6B7280}
  32. .wr-search-clear svg{width:14px;height:14px}
  33. .wr-search-kbd-hint{display:none;align-items:center;padding:3px 8px;background:#fff;border:1px solid #E5E7EB;border-radius:6px;font-size:10.5px;font-weight:600;color:#6B7280;font-family:inherit;letter-spacing:.02em;white-space:nowrap;flex-shrink:0}
  34. .wr-search-input-wrap:focus-within .wr-search-kbd-hint{display:inline-flex}
  35. /* Chips keywords */
  36. .search-keywords{display:flex;flex-wrap:wrap;gap:6px;margin:0 4px 18px}
  37. .search-keyword-chip{display:inline-flex;align-items:center;gap:5px;padding:6px 12px;border-radius:100px;background:#F5F3FF;color:var(--theme-color,#6C3AED);font-size:12.5px;font-weight:500;cursor:pointer;border:1px solid transparent;transition:background .15s,border-color .15s,transform .15s;font-family:inherit}
  38. .search-keyword-chip:hover{background:#EDE9FE;border-color:rgba(108,58,237,.2);transform:translateY(-1px);color:var(--theme-color,#6C3AED)}
  39. .search-keyword-chip.active{background:var(--theme-color,#6C3AED);color:#fff;border-color:var(--theme-color,#6C3AED)}
  40. .search-keyword-chip-icon{font-size:13px;line-height:1}
  41. .search-keywords-label{font-size:11px;font-weight:600;color:#9CA3AF;text-transform:uppercase;letter-spacing:.06em;margin-right:4px;align-self:center}
  42. /* ─── Sections de filtres SEO (chips → liens directs vers /jobs/{slug}) ─── */
  43. .filter-section{margin:0 0 14px}
  44. .filter-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;padding:0 4px;flex-wrap:wrap}
  45. @media(max-width:540px){
  46. .dash-filter-search{max-width:100%;width:100%}
  47. }
  48. .filter-section-title{display:flex;align-items:center;gap:7px;font-size:11.5px;font-weight:700;color:#6B7280;text-transform:uppercase;letter-spacing:.06em;margin:0}
  49. .filter-section-title-icon{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;background:#F5F3FF;color:var(--theme-color,#6C3AED)}
  50. .filter-section-title-icon svg{width:11px;height:11px}
  51. .filter-section-toggle{background:none;border:none;font-size:11px;color:var(--theme-color,#6C3AED);font-weight:600;cursor:pointer;font-family:inherit;padding:4px 8px;border-radius:6px;transition:background .15s}
  52. .filter-section-toggle:hover{background:#F5F3FF}
  53. .filter-chips{display:flex;flex-wrap:wrap;gap:6px;padding:0 4px}
  54. .filter-chip{display:inline-flex;align-items:center;gap:5px;padding:7px 13px;border-radius:100px;background:#fff;color:#374151;font-size:12.5px;font-weight:500;cursor:pointer;border:1px solid #E5E7EB;text-decoration:none;transition:background .15s,border-color .15s,transform .15s,color .15s;font-family:inherit;white-space:nowrap}
  55. .filter-chip:hover{background:#F5F3FF;border-color:rgba(108,58,237,.3);color:var(--theme-color,#6C3AED);transform:translateY(-1px)}
  56. .filter-chip.active{background:var(--theme-color,#6C3AED);border-color:var(--theme-color,#6C3AED);color:#fff}
  57. .filter-chip.active:hover{background:#5B21B6;color:#fff}
  58. .filter-chip-icon{font-size:14px;line-height:1}
  59. .filter-chip-arrow{display:inline-flex;color:currentColor;opacity:.5;margin-left:2px}
  60. .filter-chip-arrow svg{width:10px;height:10px}
  61. /* Mode "compact" : sections initialement collapsées si > 6 items */
  62. .filter-chips.collapsed{max-height:80px;overflow:hidden;position:relative}
  63. .filter-chips.collapsed::after{content:"";position:absolute;left:0;right:0;bottom:0;height:30px;background:linear-gradient(to bottom,transparent,#FAFAFA);pointer-events:none}
  64. /* ─── Bouton "Voir plus" sous les chips villes/métiers ─── */
  65. .dash-filter-more-wrap{display:flex;justify-content:center;margin-top:10px;padding:0 4px}
  66. .dash-filter-more-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 16px;background:transparent;color:var(--theme-color,#6C3AED);border:1px dashed rgba(108,58,237,.35);border-radius:100px;font-size:12px;font-weight:600;font-family:inherit;cursor:pointer;transition:background .15s,border-style .15s,border-color .15s}
  67. .dash-filter-more-btn:hover{background:#F5F3FF;border-style:solid;border-color:var(--theme-color,#6C3AED)}
  68. .dash-filter-more-icon{display:inline-flex;align-items:center;color:currentColor;transition:transform .2s}
  69. .dash-filter-more-icon svg{width:12px;height:12px}
  70. .dash-filter-more-btn.is-expanded .dash-filter-more-icon{transform:rotate(180deg)}
  71. /* ─── Input de recherche dans le header (toujours visible) ─── */
  72. .dash-filter-search{display:block;position:relative;flex:1;max-width:240px}
  73. @keyframes dashFilterFadeIn{from{opacity:0;transform:translateY(-2px)}to{opacity:1;transform:translateY(0)}}
  74. .dash-filter-search-input{width:100%;padding:5px 10px 5px 28px;border:1px solid #E5E7EB;border-radius:100px;font-size:12px;color:#1E1B2E;background:#FAFAFA;font-family:inherit;outline:none;box-sizing:border-box;transition:border-color .15s,background .15s}
  75. .dash-filter-search-input:focus{border-color:rgba(108,58,237,.4);background:#fff}
  76. .dash-filter-search-input::placeholder{color:#9CA3AF}
  77. .dash-filter-search-icon{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:#9CA3AF;pointer-events:none;display:flex}
  78. .dash-filter-search-icon svg{width:11px;height:11px}
  79. /* État "aucun résultat" sous le filtre live */
  80. .dash-filter-empty{display:none;padding:14px 12px;font-size:12.5px;color:#9CA3AF;font-style:italic;text-align:center;width:100%}
  81. .filter-chips.is-no-results .dash-filter-empty{display:block}
  82. /* Bandeau résultats */
  83. .jobs-results-info{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 4px 14px;font-size:12px;color:#6B7280}
  84. .jobs-results-info strong{color:#1E1B2E;font-weight:700}
  85. /* Liste cards (style "similar-card" comme dans show.html.twig) */
  86. .jobs-list{display:flex;flex-direction:column;gap:10px}
  87. .jobs-card{background:#fff;border-radius:14px;padding:14px;box-shadow:0 0 16px 0 rgba(0,0,0,0.04);display:flex;align-items:center;gap:14px;text-decoration:none;color:inherit;transition:transform .15s,box-shadow .2s}
  88. .jobs-card:hover{transform:translateY(-1px);box-shadow:0 4px 20px rgba(108,58,237,.1);color:inherit}
  89. .jobs-card-logo{width:50px;height:50px;border-radius:12px;background:linear-gradient(135deg,#EDE9FE,#DDD6FE);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:20px;color:var(--theme-color,#6C3AED);flex-shrink:0;overflow:hidden}
  90. .jobs-card-logo img{width:100%;height:100%;object-fit:cover;border-radius:8px}
  91. .jobs-card-logo svg{width:24px;height:24px;opacity:.85}
  92. .jobs-card-info{flex:1;min-width:0}
  93. .jobs-card-title{font-size:14px;font-weight:700;color:#1E1B2E;line-height:1.3;margin:0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  94. .jobs-card-meta{font-size:12px;color:#6B7280;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  95. .jobs-card-salary{font-size:11px;font-weight:600;color:var(--theme-color,#6C3AED);background:#F5F3FF;padding:3px 9px;border-radius:100px;white-space:nowrap;flex-shrink:0}
  96. .jobs-card-arrow{flex-shrink:0;color:#9CA3AF;transition:color .15s,transform .15s}
  97. .jobs-card:hover .jobs-card-arrow{color:var(--theme-color,#6C3AED);transform:translateX(2px)}
  98. .jobs-card-arrow svg{width:18px;height:18px}
  99. /* Skeleton (pendant chargement) */
  100. .jobs-skeleton{display:flex;flex-direction:column;gap:10px}
  101. .jobs-skel-card{background:#fff;border-radius:14px;padding:14px;display:flex;align-items:center;gap:14px;box-shadow:0 0 16px 0 rgba(0,0,0,0.04)}
  102. .jobs-skel-logo{width:50px;height:50px;border-radius:12px;background:linear-gradient(90deg,#F3F4F6 0%,#E5E7EB 50%,#F3F4F6 100%);background-size:200% 100%;animation:jobsSkelShim 1.4s ease-in-out infinite;flex-shrink:0}
  103. .jobs-skel-info{flex:1}
  104. .jobs-skel-line{height:14px;background:linear-gradient(90deg,#F3F4F6 0%,#E5E7EB 50%,#F3F4F6 100%);background-size:200% 100%;animation:jobsSkelShim 1.4s ease-in-out infinite;border-radius:4px;margin-bottom:6px}
  105. .jobs-skel-line.s1{width:60%}
  106. .jobs-skel-line.s2{width:40%;height:12px;margin-bottom:0}
  107. @keyframes jobsSkelShim{0%{background-position:200% 0}100%{background-position:-200% 0}}
  108. /* Bouton charger plus */
  109. .jobs-load-more{display:flex;width:100%;padding:14px;margin-top:16px;background:#fff;color:var(--theme-color,#6C3AED);border:1.5px dashed rgba(108,58,237,.35);border-radius:12px;font-size:13.5px;font-weight:700;cursor:pointer;font-family:inherit;align-items:center;justify-content:center;gap:8px;transition:background .15s,border-color .15s}
  110. .jobs-load-more:hover{background:#F5F3FF;border-color:var(--theme-color,#6C3AED);border-style:solid;color:var(--theme-color,#6C3AED)}
  111. .jobs-load-more svg{width:14px;height:14px}
  112. .jobs-load-more:disabled{opacity:.6;cursor:not-allowed}
  113. .jobs-load-more.loading{cursor:wait}
  114. .jobs-load-spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(108,58,237,.2);border-top-color:var(--theme-color,#6C3AED);border-radius:50%;animation:jobsLoadSpin .7s linear infinite}
  115. @keyframes jobsLoadSpin{to{transform:rotate(360deg)}}
  116. /* État vide */
  117. .jobs-empty{text-align:center;padding:40px 20px;background:#fff;border-radius:14px;box-shadow:0 0 16px 0 rgba(0,0,0,0.04)}
  118. .jobs-empty-icon{display:inline-flex;align-items:center;justify-content:center;width:56px;height:56px;border-radius:16px;background:#F5F3FF;color:var(--theme-color,#6C3AED);margin-bottom:14px}
  119. .jobs-empty-icon svg{width:24px;height:24px}
  120. .jobs-empty-title{font-size:16px;font-weight:700;color:#1E1B2E;margin:0 0 6px}
  121. .jobs-empty-text{font-size:13px;color:#6B7280;margin:0}
  122. </style>
  123. {% endblock css %}
  124. {% block body %}
  125. <div class="jobs-dash">
  126. <div class="jobs-dash-header">
  127. {% if currentSubKeyword is defined and currentSubKeyword is not null and currentCityFilter is defined and currentCityFilter is not null %}
  128. {# Cas combiné : ville + keyword #}
  129. <h1 class="jobs-dash-title">
  130. {% if currentCityFilter.icon is not empty %}{{ currentCityFilter.icon }} {% endif %}{{ currentSubKeyword }} {{ 'company.dashboard.cityHeadingIn'|trans }} {{ currentCityFilter.label }}
  131. </h1>
  132. <p class="jobs-dash-subtitle">{{ 'company.dashboard.cityKeywordSubtitle'|trans({'%keyword%': currentSubKeyword, '%city%': currentCityFilter.label}) }}</p>
  133. {% elseif currentFilter is defined and currentFilter is not null %}
  134. <h1 class="jobs-dash-title">
  135. {% if currentFilter.icon is not empty %}{{ currentFilter.icon }} {% endif %}{{ currentFilter.shortTitle|default(currentFilter.label) }}
  136. </h1>
  137. {% if currentFilter.shortDescription is not empty %}
  138. <p class="jobs-dash-subtitle">{{ currentFilter.shortDescription }}</p>
  139. {% else %}
  140. <p class="jobs-dash-subtitle">{{ 'company.dashboard.subtitle'|trans }}</p>
  141. {% endif %}
  142. {% else %}
  143. <h1 class="jobs-dash-title">{{ 'company.dashboard.heading'|trans }}</h1>
  144. <p class="jobs-dash-subtitle">{{ 'company.dashboard.subtitle'|trans }}</p>
  145. {% endif %}
  146. {# Badge "Ville active" + bouton "Tout voir" / ALL pour reset #}
  147. {% if currentCityFilter is defined and currentCityFilter is not null %}
  148. <div class="city-context-bar">
  149. <span class="city-context-label">{{ 'company.dashboard.contextLabel'|trans }}</span>
  150. <span class="city-context-badge">
  151. {% if currentCityFilter.icon is not empty %}<span class="city-context-badge-icon">{{ currentCityFilter.icon }}</span>{% endif %}
  152. <span>{{ currentCityFilter.label }}</span>
  153. </span>
  154. <a href="{% if app.request.locale == 'en' %}{{ path('whileresume_companies_list') }}{% else %}{{ path('locale_whileresume_companies_list',{'_locale':app.request.locale}) }}{% endif %}"
  155. class="city-context-clear-btn"
  156. title="{{ 'company.dashboard.clearCity'|trans }}">
  157. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6" transform="rotate(180 12 12)"/></svg>
  158. {{ 'company.dashboard.allCities'|trans }}
  159. </a>
  160. </div>
  161. {% endif %}
  162. </div>
  163. {# ═══ Widget recherche ═══ #}
  164. <div class="wr-search-widget">
  165. <div class="wr-search-input-wrap">
  166. <span class="wr-search-icon">
  167. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
  168. </span>
  169. <input type="text" id="dashSearchInput" class="wr-search-input"
  170. placeholder="{% if currentCityFilter is defined and currentCityFilter is not null %}{{ 'company.dashboard.searchInCity'|trans({'%city%': currentCityFilter.label}) }}{% else %}{{ 'company.dashboard.searchPlaceholder'|trans }}{% endif %}"
  171. value="{{ initialQuery|default('') }}"
  172. autocomplete="off">
  173. <span class="wr-search-kbd-hint" id="dashSearchKbdHint">{{ 'company.dashboard.pressEnter'|trans }}</span>
  174. <button type="button" id="dashSearchClear" class="wr-search-clear" aria-label="Clear">
  175. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
  176. </button>
  177. </div>
  178. </div>
  179. {# Chips keywords cliquables #}
  180. {% if searchKeywords is defined and searchKeywords|length > 0 %}
  181. <div class="search-keywords">
  182. <span class="search-keywords-label">{{ 'company.dashboard.keywordsLabel'|trans }}</span>
  183. {% for kw in searchKeywords %}
  184. {# Slug pour l'URL : translitération des accents FR + lowercase + remplacement caractères spéciaux #}
  185. {% set kwSlug = kw.label
  186. |replace({
  187. 'à':'a','â':'a','ä':'a','á':'a','ã':'a','å':'a',
  188. 'À':'a','Â':'a','Ä':'a','Á':'a','Ã':'a','Å':'a',
  189. 'é':'e','è':'e','ê':'e','ë':'e','ẽ':'e',
  190. 'É':'e','È':'e','Ê':'e','Ë':'e',
  191. 'î':'i','ï':'i','ì':'i','í':'i',
  192. 'Î':'i','Ï':'i','Ì':'i','Í':'i',
  193. 'ô':'o','ö':'o','ò':'o','ó':'o','õ':'o','ø':'o',
  194. 'Ô':'o','Ö':'o','Ò':'o','Ó':'o','Õ':'o','Ø':'o',
  195. 'û':'u','ü':'u','ù':'u','ú':'u',
  196. 'Û':'u','Ü':'u','Ù':'u','Ú':'u',
  197. 'ÿ':'y','ý':'y','Ÿ':'y','Ý':'y',
  198. 'ç':'c','Ç':'c',
  199. 'ñ':'n','Ñ':'n',
  200. 'œ':'oe','Œ':'oe','æ':'ae','Æ':'ae',
  201. "'":'-',' ':'-','/':'-','_':'-','.':'-',',':'-',
  202. '(':'','*':'',')':'','&':'-and-','+':'-plus-',
  203. '#':'','?':'','!':'','"':'','’':'-','`':'-'
  204. })
  205. |lower
  206. |replace({'--':'-','---':'-'})
  207. |trim('-') %}
  208. {% if currentCityFilter is defined and currentCityFilter is not null %}
  209. {# Contexte ville : chip = lien vers /companies/ville/keyword #}
  210. <a href="{% if app.request.locale == 'en' %}{{ path('cvs_application_companies_filter_keyword',{'slug':currentCityFilter.slug,'keyword':kwSlug}) }}{% else %}{{ path('locale_cvs_application_companies_filter_keyword',{'_locale':app.request.locale,'slug':currentCityFilter.slug,'keyword':kwSlug}) }}{% endif %}"
  211. class="search-keyword-chip{% if currentSubKeywordSlug is defined and currentSubKeywordSlug == kwSlug %} active{% endif %}">
  212. {% if kw.icon is not empty %}<span class="search-keyword-chip-icon">{{ kw.icon }}</span>{% endif %}
  213. <span>{{ kw.label }}</span>
  214. </a>
  215. {% else %}
  216. {# Pas de contexte ville : chip pré-remplit la search (comportement existant) #}
  217. <button type="button"
  218. class="search-keyword-chip"
  219. data-keyword="{{ kw.searchKeyword|default(kw.label) }}">
  220. {% if kw.icon is not empty %}<span class="search-keyword-chip-icon">{{ kw.icon }}</span>{% endif %}
  221. <span>{{ kw.label }}</span>
  222. </button>
  223. {% endif %}
  224. {% endfor %}
  225. </div>
  226. {% endif %}
  227. {# ═══ Filtres SEO : Villes (chips → /jobs/{slug}) — 14 visibles + reste cliqué pour révéler ═══ #}
  228. {% if sidebarFiltersCities is defined and sidebarFiltersCities|length > 0 %}
  229. {% set citiesInitial = 14 %}
  230. <div class="filter-section" id="filterSectionCities">
  231. <div class="filter-section-head">
  232. <h3 class="filter-section-title">
  233. <span class="filter-section-title-icon">
  234. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
  235. </span>
  236. {{ 'company.dashboard.popularCities'|trans }}
  237. </h3>
  238. {# Input de recherche (visible uniquement quand expanded) #}
  239. {% if sidebarFiltersCities|length > citiesInitial %}
  240. <div class="dash-filter-search">
  241. <span class="dash-filter-search-icon">
  242. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
  243. </span>
  244. <input type="text" class="dash-filter-search-input" data-search-target="filterChipsCities" placeholder="{{ 'company.dashboard.filterSearchCity'|trans }}" autocomplete="off">
  245. </div>
  246. {% endif %}
  247. </div>
  248. <div class="filter-chips dash-filter-chips" id="filterChipsCities" data-initial="{{ citiesInitial }}">
  249. {% for f in sidebarFiltersCities %}
  250. <a href="{% if app.request.locale == 'en' %}{{ path('cvs_application_companies_filter',{'slug':f.slug}) }}{% else %}{{ path('locale_cvs_application_companies_filter',{'_locale':app.request.locale,'slug':f.slug}) }}{% endif %}"
  251. class="filter-chip dash-filter-item{% if currentFilter is defined and currentFilter is not null and currentFilter.slug == f.slug %} active{% endif %}"
  252. data-filter-slug="{{ f.slug }}"
  253. data-filter-label="{{ f.label }}"
  254. data-index="{{ loop.index0 }}"{% if loop.index0 >= citiesInitial %} style="display:none"{% endif %}>
  255. {% if f.icon is not empty %}<span class="filter-chip-icon">{{ f.icon }}</span>{% endif %}
  256. <span>{{ f.label }}</span>
  257. <span class="filter-chip-arrow">
  258. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg>
  259. </span>
  260. </a>
  261. {% endfor %}
  262. <div class="dash-filter-empty">{{ 'company.dashboard.filterNoResults'|trans }}</div>
  263. </div>
  264. {% if sidebarFiltersCities|length > citiesInitial %}
  265. <div class="dash-filter-more-wrap">
  266. <button type="button" class="dash-filter-more-btn" data-target="filterChipsCities" data-section="filterSectionCities">
  267. <span class="dash-filter-more-icon">
  268. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
  269. </span>
  270. <span class="dash-filter-more-label" data-more="{{ 'company.dashboard.showMoreCities'|trans({'%n%': sidebarFiltersCities|length - citiesInitial}) }}" data-less="{{ 'company.dashboard.showLessCities'|trans }}">{{ 'company.dashboard.showMoreCities'|trans({'%n%': sidebarFiltersCities|length - citiesInitial}) }}</span>
  271. </button>
  272. </div>
  273. {% endif %}
  274. </div>
  275. {% endif %}
  276. {# ═══ Filtres SEO : Métiers (chips → /jobs/{slug}) — 8 visibles + reste cliqué pour révéler ═══ #}
  277. {#% if sidebarFiltersCategories is defined and sidebarFiltersCategories|length > 0 %}
  278. {% set categoriesInitial = 8 %}
  279. <div class="filter-section" id="filterSectionCategories">
  280. <div class="filter-section-head">
  281. <h3 class="filter-section-title">
  282. <span class="filter-section-title-icon">
  283. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a4 4 0 00-8 0v2"/></svg>
  284. </span>
  285. {{ 'company.dashboard.popularCategories'|trans }}
  286. </h3>
  287. {% if sidebarFiltersCategories|length > categoriesInitial %}
  288. <div class="dash-filter-search">
  289. <span class="dash-filter-search-icon">
  290. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
  291. </span>
  292. <input type="text"
  293. class="dash-filter-search-input"
  294. data-search-target="filterChipsCategories"
  295. placeholder="{{ 'company.dashboard.filterSearchCategory'|trans }}"
  296. autocomplete="off">
  297. </div>
  298. {% endif %}
  299. </div>
  300. <div class="filter-chips dash-filter-chips" id="filterChipsCategories" data-initial="{{ categoriesInitial }}">
  301. {% for f in sidebarFiltersCategories %}
  302. <a href="{% if app.request.locale == 'en' %}{{ path('cvs_application_companies_filter',{'slug':f.slug}) }}{% else %}{{ path('locale_cvs_application_companies_filter',{'_locale':app.request.locale,'slug':f.slug}) }}{% endif %}"
  303. class="filter-chip dash-filter-item{% if currentFilter is defined and currentFilter is not null and currentFilter.slug == f.slug %} active{% endif %}"
  304. data-filter-slug="{{ f.slug }}"
  305. data-filter-label="{{ f.label }}"
  306. data-index="{{ loop.index0 }}"{% if loop.index0 >= categoriesInitial %} style="display:none"{% endif %}>
  307. {% if f.icon is not empty %}<span class="filter-chip-icon">{{ f.icon }}</span>{% endif %}
  308. <span>{{ f.label }}</span>
  309. <span class="filter-chip-arrow">
  310. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg>
  311. </span>
  312. </a>
  313. {% endfor %}
  314. <div class="dash-filter-empty">{{ 'company.dashboard.filterNoResults'|trans }}</div>
  315. </div>
  316. {% if sidebarFiltersCategories|length > categoriesInitial %}
  317. <div class="dash-filter-more-wrap">
  318. <button type="button" class="dash-filter-more-btn" data-target="filterChipsCategories" data-section="filterSectionCategories">
  319. <span class="dash-filter-more-icon">
  320. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
  321. </span>
  322. <span class="dash-filter-more-label" data-more="{{ 'company.dashboard.showMoreCategories'|trans({'%n%': sidebarFiltersCategories|length - categoriesInitial}) }}" data-less="{{ 'company.dashboard.showLessCategories'|trans }}">{{ 'company.dashboard.showMoreCategories'|trans({'%n%': sidebarFiltersCategories|length - categoriesInitial}) }}</span>
  323. </button>
  324. </div>
  325. {% endif %}
  326. </div>
  327. {% endif %}
  328. #}
  329. {# Bandeau d'info (compteur de résultats) #}
  330. <div class="jobs-results-info" id="jobsResultsInfo">
  331. <span id="jobsResultsCount">{{ 'company.dashboard.loading'|trans }}</span>
  332. </div>
  333. {# Liste des offres (peuplée en JS) #}
  334. <div class="jobs-list" id="jobsList">
  335. <div class="jobs-skeleton">
  336. {% for i in 1..5 %}
  337. <div class="jobs-skel-card">
  338. <div class="jobs-skel-logo"></div>
  339. <div class="jobs-skel-info">
  340. <div class="jobs-skel-line s1"></div>
  341. <div class="jobs-skel-line s2"></div>
  342. </div>
  343. </div>
  344. {% endfor %}
  345. </div>
  346. </div>
  347. {# Bouton "Charger plus" #}
  348. <button type="button" class="jobs-load-more" id="jobsLoadMore" style="display:none;">
  349. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
  350. <span id="jobsLoadMoreLabel">{{ 'company.dashboard.loadMore'|trans }}</span>
  351. </button>
  352. </div>
  353. {% endblock body %}
  354. {% block footerjs %}
  355. <script>
  356. (function(){
  357. 'use strict';
  358. try {
  359. var LOCALE = '{{ app.request.locale }}';
  360. // SVG fallback logo entreprise (utilisé si pas de logo ou si l'URL est cassée)
  361. var ENTERPRISE_LOGO_FALLBACK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7l8-4v18"/><path d="M19 21V11l-6-4"/><path d="M9 9h.01"/><path d="M9 13h.01"/><path d="M9 17h.01"/></svg>';
  362. var SEARCH_API_URL = '{{ app.request.locale == "en" ? path("cvs_application_companies_api_search") : path("locale_cvs_application_companies_api_search",{"_locale":app.request.locale}) }}';
  363. var COMPANY_SHOW_URL_TEMPLATE = '{{ app.request.locale == "en" ? path("cvs_application_company_show",{"slug":"__SLUG__"}) : path("locale_cvs_application_company_show",{"_locale":app.request.locale,"slug":"__SLUG__"}) }}';
  364. var PAGE_SIZE = 10;
  365. var currentQuery = {{ initialQuery|default('')|json_encode|raw }};
  366. var currentOffset = 0;
  367. var currentTotal = 0;
  368. var isFetching = false;
  369. // Contexte ville (vient du serveur si on est sur /companies/paris/...)
  370. var CITY_KEYWORD = {{ currentCityKeyword|default('')|json_encode|raw }};
  371. // Contexte catégorie (vient du serveur si on est sur une page catégorie)
  372. var CATEGORY_KEYWORD = {{ currentCategoryKeyword|default('')|json_encode|raw }};
  373. // Slug ville pour générer les URLs de redirection au submit
  374. var CITY_SLUG = {{ currentCityFilter is defined and currentCityFilter is not null ? currentCityFilter.slug|json_encode|raw : 'null' }};
  375. // URL pour le dashboard global (sans ville)
  376. var DASHBOARD_URL = '{{ app.request.locale == "en" ? path("whileresume_companies_list") : path("locale_whileresume_companies_list",{"_locale":app.request.locale}) }}';
  377. // Templates URL pour /jobs/{citySlug} et /jobs/{citySlug}/{keyword}
  378. var CITY_LANDING_URL_TEMPLATE = {{ currentCityFilter is defined and currentCityFilter is not null
  379. ? (app.request.locale == 'en'
  380. ? path('cvs_application_companies_filter',{'slug':currentCityFilter.slug})
  381. : path('locale_cvs_application_companies_filter',{'_locale':app.request.locale,'slug':currentCityFilter.slug}))|json_encode|raw
  382. : 'null' }};
  383. var CITY_KEYWORD_URL_TEMPLATE = {{ currentCityFilter is defined and currentCityFilter is not null
  384. ? (app.request.locale == 'en'
  385. ? path('cvs_application_companies_filter_keyword',{'slug':currentCityFilter.slug,'keyword':'kwplaceholder'})
  386. : path('locale_cvs_application_companies_filter_keyword',{'_locale':app.request.locale,'slug':currentCityFilter.slug,'keyword':'kwplaceholder'}))|json_encode|raw
  387. : 'null' }};
  388. // Liste des keywords BDD avec leur slug (pour le matching côté client au submit)
  389. var KEYWORDS_DATA = [
  390. {% for kw in searchKeywords|default([]) %}
  391. {% set kwSlugForJs = kw.label
  392. |replace({
  393. 'à':'a','â':'a','ä':'a','á':'a','ã':'a','å':'a',
  394. 'À':'a','Â':'a','Ä':'a','Á':'a','Ã':'a','Å':'a',
  395. 'é':'e','è':'e','ê':'e','ë':'e','ẽ':'e',
  396. 'É':'e','È':'e','Ê':'e','Ë':'e',
  397. 'î':'i','ï':'i','ì':'i','í':'i',
  398. 'Î':'i','Ï':'i','Ì':'i','Í':'i',
  399. 'ô':'o','ö':'o','ò':'o','ó':'o','õ':'o','ø':'o',
  400. 'Ô':'o','Ö':'o','Ò':'o','Ó':'o','Õ':'o','Ø':'o',
  401. 'û':'u','ü':'u','ù':'u','ú':'u',
  402. 'Û':'u','Ü':'u','Ù':'u','Ú':'u',
  403. 'ÿ':'y','ý':'y','Ÿ':'y','Ý':'y',
  404. 'ç':'c','Ç':'c','ñ':'n','Ñ':'n',
  405. 'œ':'oe','Œ':'oe','æ':'ae','Æ':'ae',
  406. "'":'-',' ':'-','/':'-','_':'-','.':'-',',':'-',
  407. '(':'','*':'',')':'','&':'-and-','+':'-plus-',
  408. '#':'','?':'','!':'','"':'','’':'-','`':'-'
  409. })
  410. |lower
  411. |replace({'--':'-','---':'-'})
  412. |trim('-') %}
  413. { label: {{ kw.label|json_encode|raw }}, searchKeyword: {{ (kw.searchKeyword ?: kw.label)|json_encode|raw }}, slug: {{ kwSlugForJs|json_encode|raw }} }{% if not loop.last %},{% endif %}
  414. {% endfor %}
  415. ];
  416. var input = document.getElementById('dashSearchInput');
  417. var clearBtn = document.getElementById('dashSearchClear');
  418. var listEl = document.getElementById('jobsList');
  419. var infoEl = document.getElementById('jobsResultsCount');
  420. var loadMoreBtn = document.getElementById('jobsLoadMore');
  421. var loadMoreLabel = document.getElementById('jobsLoadMoreLabel');
  422. function escapeHtml(s){
  423. if(s == null) return '';
  424. return String(s).replace(/[&<>"']/g, function(c){
  425. return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
  426. });
  427. }
  428. function buildJobCard(item){
  429. // Structure entreprise : item.name (companyName), item.shortTitle, item.category, item.city, item.logo
  430. // Logo : <img> si l'API renvoie une URL Vich, sinon SVG immeuble.
  431. // L'onerror bascule sur le SVG si l'image est cassée (404).
  432. var logoHtml = item.logo
  433. ? '<img src="' + escapeHtml(item.logo) + '" alt="" onerror="this.outerHTML=ENTERPRISE_LOGO_FALLBACK;">'
  434. : ENTERPRISE_LOGO_FALLBACK;
  435. var meta = [];
  436. if(item.category) meta.push(escapeHtml(item.category));
  437. if(item.city) meta.push(escapeHtml(item.city));
  438. // Sous-titre = shortTitle (tagline) si différent du nom
  439. var sub = '';
  440. if(item.shortTitle && item.shortTitle !== item.name){
  441. sub = escapeHtml(item.shortTitle);
  442. }
  443. var url = item.url || COMPANY_SHOW_URL_TEMPLATE.replace('__SLUG__', encodeURIComponent(item.slug));
  444. var a = document.createElement('a');
  445. a.href = url;
  446. a.className = 'jobs-card';
  447. a.innerHTML =
  448. '<div class="jobs-card-logo">' + logoHtml + '</div>' +
  449. '<div class="jobs-card-info">' +
  450. '<div class="jobs-card-title">' + escapeHtml(item.name || '') + '</div>' +
  451. '<div class="jobs-card-meta">' + (sub ? sub + (meta.length ? ' · ' : '') : '') + meta.join(' · ') + '</div>' +
  452. '</div>' +
  453. '<span class="jobs-card-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></span>';
  454. return a;
  455. }
  456. function renderEmpty(){
  457. listEl.innerHTML =
  458. '<div class="jobs-empty">' +
  459. '<div class="jobs-empty-icon">' +
  460. '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>' +
  461. '</div>' +
  462. '<div class="jobs-empty-title">{{ 'company.dashboard.emptyTitle'|trans|escape('js') }}</div>' +
  463. '<div class="jobs-empty-text">{{ 'company.dashboard.emptyText'|trans|escape('js') }}</div>' +
  464. '</div>';
  465. }
  466. function setLoadMoreState(state){
  467. if(!loadMoreBtn) return;
  468. if(state === 'loading'){
  469. loadMoreBtn.classList.add('loading');
  470. loadMoreBtn.disabled = true;
  471. loadMoreLabel.innerHTML = '<span class="jobs-load-spinner"></span> {{ 'company.dashboard.loadingMore'|trans|escape('js') }}';
  472. } else {
  473. loadMoreBtn.classList.remove('loading');
  474. loadMoreBtn.disabled = false;
  475. loadMoreLabel.textContent = '{{ 'company.dashboard.loadMore'|trans|escape('js') }}';
  476. }
  477. }
  478. function fetchJobs(query, offset, append){
  479. if(isFetching) return;
  480. isFetching = true;
  481. if(!append){
  482. // Nouvelle recherche : on remet à zéro et affiche skeleton
  483. listEl.innerHTML =
  484. '<div class="jobs-skeleton">' +
  485. Array(5).fill(0).map(function(){
  486. return '<div class="jobs-skel-card"><div class="jobs-skel-logo"></div><div class="jobs-skel-info"><div class="jobs-skel-line s1"></div><div class="jobs-skel-line s2"></div></div></div>';
  487. }).join('') +
  488. '</div>';
  489. if(infoEl) infoEl.textContent = '{{ 'company.dashboard.loading'|trans|escape('js') }}';
  490. if(loadMoreBtn) loadMoreBtn.style.display = 'none';
  491. } else {
  492. setLoadMoreState('loading');
  493. }
  494. var url = SEARCH_API_URL + '?q=' + encodeURIComponent(query || '') + '&locale=' + LOCALE + '&limit=' + PAGE_SIZE + '&offset=' + offset;
  495. if(CITY_KEYWORD){ url += '&city=' + encodeURIComponent(CITY_KEYWORD); }
  496. if(CATEGORY_KEYWORD){ url += '&category=' + encodeURIComponent(CATEGORY_KEYWORD); }
  497. fetch(url, {headers:{'Accept':'application/json'}})
  498. .then(function(r){ return r.ok ? r.json() : null; })
  499. .then(function(data){
  500. isFetching = false;
  501. if(!data){
  502. if(!append) renderEmpty();
  503. setLoadMoreState('idle');
  504. return;
  505. }
  506. var items = data.items || [];
  507. currentTotal = data.total || 0;
  508. if(!append){
  509. listEl.innerHTML = '';
  510. if(items.length === 0){
  511. renderEmpty();
  512. if(infoEl) infoEl.innerHTML = '<strong>0</strong> {{ 'company.dashboard.resultsCount'|trans|escape('js') }}';
  513. if(loadMoreBtn) loadMoreBtn.style.display = 'none';
  514. return;
  515. }
  516. }
  517. items.forEach(function(item){
  518. listEl.appendChild(buildJobCard(item));
  519. });
  520. currentOffset = offset + items.length;
  521. if(infoEl){
  522. infoEl.innerHTML = '<strong>' + currentTotal + '</strong> {{ 'company.dashboard.resultsCount'|trans|escape('js') }}';
  523. }
  524. // Afficher / cacher "Charger plus"
  525. if(loadMoreBtn){
  526. if(currentOffset < currentTotal){
  527. loadMoreBtn.style.display = 'flex';
  528. } else {
  529. loadMoreBtn.style.display = 'none';
  530. }
  531. setLoadMoreState('idle');
  532. }
  533. })
  534. .catch(function(err){
  535. isFetching = false;
  536. console.error('[whr-dash] fetch error', err);
  537. setLoadMoreState('idle');
  538. });
  539. }
  540. /**
  541. * Slugifie un texte côté client. DOIT produire EXACTEMENT le même slug
  542. * que la méthode PHP slugify() du JobsController et que le filtre Twig
  543. * utilisé pour rendre les chips.
  544. */
  545. function slugify(text){
  546. if(!text) return '';
  547. var t = String(text);
  548. // Pré-remplacements sémantiques
  549. t = t.replace(/&/g, '-and-').replace(/\+/g, '-plus-')
  550. .replace(/[œŒ]/g, 'oe').replace(/[æÆ]/g, 'ae');
  551. // Translitération accents → ASCII via NFD + strip diacritics
  552. t = t.normalize ? t.normalize('NFD').replace(/[\u0300-\u036f]/g, '') : t;
  553. t = t.toLowerCase();
  554. // Tout ce qui n'est pas a-z 0-9 → tiret
  555. t = t.replace(/[^a-z0-9]+/g, '-');
  556. return t.replace(/^-+|-+$/g, '');
  557. }
  558. /**
  559. * Tente de matcher un texte tapé avec un keyword en BDD (par slug ou label).
  560. * Retourne l'objet keyword si trouvé, null sinon.
  561. */
  562. function findMatchingKeyword(text){
  563. if(!text || !KEYWORDS_DATA || !KEYWORDS_DATA.length) return null;
  564. var slug = slugify(text);
  565. if(!slug) return null;
  566. for(var i = 0; i < KEYWORDS_DATA.length; i++){
  567. var kw = KEYWORDS_DATA[i];
  568. // Match sur slug exact OU label/searchKeyword exact (case insensitive)
  569. if(kw.slug === slug) return kw;
  570. if(slugify(kw.searchKeyword) === slug) return kw;
  571. }
  572. return null;
  573. }
  574. /**
  575. * Au submit : décide où rediriger l'utilisateur en fonction du contexte.
  576. * - Si on est sur une ville et le texte matche un keyword BDD → /jobs/{ville}/{slug-kw}
  577. * - Sinon si on est sur une ville → /jobs/{ville}?search={texte}
  578. * - Sinon (pas de ville) → /jobs?q={texte}
  579. * - Si texte vide ET ville définie → /jobs/{ville} (reset)
  580. * - Si texte vide ET pas de ville → /jobs (reset)
  581. */
  582. function submitSearch(query){
  583. query = (query || '').trim();
  584. // Reset (recherche vide)
  585. if(!query){
  586. if(CITY_SLUG && CITY_LANDING_URL_TEMPLATE){
  587. window.location.href = CITY_LANDING_URL_TEMPLATE;
  588. } else {
  589. window.location.href = DASHBOARD_URL;
  590. }
  591. return;
  592. }
  593. // Cas 1 : on est sur une ville
  594. if(CITY_SLUG && CITY_KEYWORD_URL_TEMPLATE){
  595. var matched = findMatchingKeyword(query);
  596. if(matched && matched.slug){
  597. // Match BDD → URL slug propre /jobs/paris/developpeur
  598. window.location.href = CITY_KEYWORD_URL_TEMPLATE.replace('kwplaceholder', encodeURIComponent(matched.slug));
  599. return;
  600. }
  601. // Pas de match → fallback ?search=texte
  602. window.location.href = CITY_LANDING_URL_TEMPLATE + '?search=' + encodeURIComponent(query);
  603. return;
  604. }
  605. // Cas 2 : pas de ville → dashboard global avec ?q=
  606. window.location.href = DASHBOARD_URL + '?q=' + encodeURIComponent(query);
  607. }
  608. /**
  609. * Lance la search côté UI (sans redirection — utilisée pour les chips et
  610. * pour la "Charger plus").
  611. */
  612. function startSearch(query){
  613. currentQuery = query;
  614. currentOffset = 0;
  615. currentTotal = 0;
  616. fetchJobs(query, 0, false);
  617. // Update clear button
  618. if(clearBtn){
  619. if(query) clearBtn.classList.add('visible');
  620. else clearBtn.classList.remove('visible');
  621. }
  622. // Update active chip (uniquement les chips qui sont des <button>, pas les <a>)
  623. document.querySelectorAll('button.search-keyword-chip').forEach(function(c){
  624. if(c.getAttribute('data-keyword') === query) c.classList.add('active');
  625. else c.classList.remove('active');
  626. });
  627. }
  628. // ═══ Submit-only : Entrée déclenche la search ET change l'URL ═══
  629. if(input){
  630. input.addEventListener('keydown', function(e){
  631. if(e.key === 'Enter' || e.keyCode === 13){
  632. e.preventDefault();
  633. submitSearch(input.value);
  634. }
  635. });
  636. // Affiche bouton clear si query initiale
  637. if(input.value.trim()){
  638. if(clearBtn) clearBtn.classList.add('visible');
  639. }
  640. }
  641. // Bouton clear : reset complet → redirection vers /jobs ou /jobs/{ville}
  642. if(clearBtn){
  643. clearBtn.addEventListener('click', function(){
  644. submitSearch('');
  645. });
  646. }
  647. // Chips keywords (cas dashboard global, sans contexte ville → comportement local)
  648. // En contexte ville, les chips sont des <a href> directs gérés par le navigateur.
  649. document.querySelectorAll('button.search-keyword-chip').forEach(function(chip){
  650. chip.addEventListener('click', function(){
  651. var kw = chip.getAttribute('data-keyword');
  652. if(!kw) return;
  653. // Sur dashboard global : on submit (redirige vers /jobs?q=kw)
  654. submitSearch(kw);
  655. });
  656. });
  657. // Bouton "Charger plus"
  658. if(loadMoreBtn){
  659. loadMoreBtn.addEventListener('click', function(){
  660. if(isFetching) return;
  661. fetchJobs(currentQuery, currentOffset, true);
  662. });
  663. }
  664. // ═══ Bouton "Voir plus" + Recherche live des chips ═══
  665. /**
  666. * Slugify simple côté UI pour matching tolérant aux accents
  667. * (ex: "developpeur" matche "Développeur")
  668. */
  669. function dashSlug(str){
  670. if(!str) return '';
  671. var t = String(str);
  672. t = t.normalize ? t.normalize('NFD').replace(/[\u0300-\u036f]/g, '') : t;
  673. return t.toLowerCase().trim();
  674. }
  675. document.querySelectorAll('.dash-filter-more-btn').forEach(function(btn){
  676. var targetId = btn.getAttribute('data-target');
  677. var sectionId = btn.getAttribute('data-section');
  678. var container = document.getElementById(targetId);
  679. var section = sectionId ? document.getElementById(sectionId) : null;
  680. if(!container) return;
  681. var label = btn.querySelector('.dash-filter-more-label');
  682. if(!label) return;
  683. var initial = parseInt(container.getAttribute('data-initial'), 10) || 14;
  684. var items = container.querySelectorAll('.dash-filter-item');
  685. var searchInput = section ? section.querySelector('.dash-filter-search-input') : null;
  686. // 2 états indépendants :
  687. // - userExpanded : l'utilisateur a cliqué sur "Voir plus"
  688. // - searchActive : l'input contient du texte (auto-expand temporaire)
  689. // Mode développé = userExpanded || searchActive
  690. var userExpanded = false;
  691. function isEffectivelyExpanded(){
  692. var hasSearch = searchInput && searchInput.value.trim().length > 0;
  693. return userExpanded || hasSearch;
  694. }
  695. function applyDisplay(){
  696. var filterText = searchInput ? searchInput.value : '';
  697. var query = dashSlug(filterText || '');
  698. var expanded = isEffectivelyExpanded();
  699. var visibleCount = 0;
  700. items.forEach(function(it, i){
  701. var label = it.getAttribute('data-filter-label') || '';
  702. var matches = !query || dashSlug(label).indexOf(query) !== -1;
  703. if(expanded){
  704. // Mode développé : on affiche tous les items qui matchent la recherche
  705. it.style.display = matches ? '' : 'none';
  706. if(matches) visibleCount++;
  707. } else {
  708. // Mode collapsé : on affiche les N premiers (pas de filtrage)
  709. it.style.display = (i < initial) ? '' : 'none';
  710. if(i < initial) visibleCount++;
  711. }
  712. });
  713. // Toggle "aucun résultat" (uniquement quand recherche active)
  714. if(query && visibleCount === 0){
  715. container.classList.add('is-no-results');
  716. } else {
  717. container.classList.remove('is-no-results');
  718. }
  719. // Synchroniser le bouton "Voir plus/moins" avec l'état effectif
  720. if(expanded){
  721. btn.classList.add('is-expanded');
  722. label.textContent = label.getAttribute('data-less');
  723. } else {
  724. btn.classList.remove('is-expanded');
  725. label.textContent = label.getAttribute('data-more');
  726. }
  727. }
  728. btn.addEventListener('click', function(){
  729. userExpanded = !userExpanded;
  730. if(!userExpanded){
  731. // Reset input quand l'utilisateur collapse manuellement
  732. if(searchInput){ searchInput.value = ''; }
  733. // Scroll vers le bouton
  734. btn.scrollIntoView({behavior:'smooth', block:'nearest'});
  735. }
  736. applyDisplay();
  737. });
  738. // Recherche live dans les chips quand on tape dans l'input
  739. // → auto-expand pendant la recherche, retour à l'état userExpanded quand on vide
  740. if(searchInput){
  741. searchInput.addEventListener('input', function(){
  742. applyDisplay();
  743. });
  744. }
  745. });
  746. // Premier chargement (vide ou avec query initiale)
  747. fetchJobs(currentQuery, 0, false);
  748. } catch(err){
  749. console.error('[whr-dash] init error', err);
  750. }
  751. })();
  752. </script>
  753. {% endblock footerjs %}