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

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