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 %}