templates/application/whileresume/application/jobs/show.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. {% block title %}{{ job.jobTitle }} — {{ job.companyName }}{% endblock title %}
  4. {% block description %}{{ job.jobSummary|striptags|slice(0, 160) }}{% endblock description %}
  5. {% block robots %}index,follow{% endblock robots %}
  6. {% block css %}
  7.     <style>
  8.         /* ═══════════════════════════════════════════════════════════════
  9.            PAGE OFFRE — Pile de cards swipeable + AJAX
  10.         ═══════════════════════════════════════════════════════════════ */
  11.         /* ─── Layout 2 colonnes ─── */
  12.         .job-layout{display:block;max-width:880px;margin:0 auto}
  13.         .job-main{width:100%}
  14.         /* ─── Bandeau d'info "swipez pour naviguer" ─── */
  15.         .swipe-banner{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 14px;background:linear-gradient(135deg,#F5F3FF,#EDE9FE);border:1px solid rgba(108,58,237,.15);border-radius:10px;margin-bottom:14px;font-size:12px}
  16.         .swipe-banner-info{display:flex;align-items:center;gap:8px;flex:1;min-width:0}
  17.         .swipe-banner-icon{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:6px;background:#fff;color:var(--theme-color,#6C3AED);flex-shrink:0;animation:bannerPulse 2s ease-in-out infinite}
  18.         .swipe-banner-icon svg{width:13px;height:13px}
  19.         @keyframes bannerPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}}
  20.         .swipe-banner-text{color:#5B21B6;font-weight:500;line-height:1.4}
  21.         .swipe-banner-text strong{color:var(--theme-color,#6C3AED);font-weight:700}
  22.         .swipe-banner-counter{font-size:11px;font-weight:700;color:var(--theme-color,#6C3AED);background:#fff;padding:3px 10px;border-radius:100px;white-space:nowrap;flex-shrink:0}
  23.         /* ─── Boutons navigation prev/next ─── */
  24.         .job-nav{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;gap:12px}
  25.         .job-nav-btn{display:flex;align-items:center;gap:8px;padding:10px 16px;border-radius:10px;font-size:13px;font-weight:600;cursor:pointer;text-decoration:none;transition:transform .15s,filter .15s,border-color .15s;font-family:inherit;border:1px solid transparent}
  26.         .job-nav-btn:hover{transform:translateY(-1px)}
  27.         .job-nav-btn-prev{background:#fff;border-color:#E5E7EB;color:#1E1B2E}
  28.         .job-nav-btn-prev:hover{border-color:var(--theme-color,#6C3AED);color:var(--theme-color,#6C3AED)}
  29.         .job-nav-btn-next{background:#1E1B2E;color:#fff}
  30.         .job-nav-btn-next:hover{filter:brightness(1.5);color:#fff}
  31.         .job-nav-btn svg{width:14px;height:14px;flex-shrink:0}
  32.         @media(max-width:480px){
  33.             .job-nav-btn span{display:none}
  34.             .job-nav-btn{padding:10px 12px}
  35.         }
  36.         /* ═══════════════════════════════════════════════════════════════
  37.            PILE DE CARDS — Card centrale + cards fantômes
  38.         ═══════════════════════════════════════════════════════════════ */
  39.         .stack-wrap{position:relative;perspective:1000px}
  40.         /* Cards fantômes derrière (effet pile) */
  41.         .stack-ghost{position:absolute;left:0;right:0;background:#fff;border-radius:16px;border:1px solid #E5E7EB;pointer-events:none;transition:transform .3s ease,opacity .3s ease}
  42.         .stack-ghost-1{top:8px;left:3%;right:3%;bottom:0;opacity:.6;transform:scale(.97)}
  43.         .stack-ghost-2{top:16px;left:6%;right:6%;bottom:0;opacity:.3;transform:scale(.94)}
  44.         @media(max-width:480px){.stack-ghost{display:none}}
  45.         /* Card centrale */
  46.         .job-card-main{position:relative;background:#fff;border-radius:16px;box-shadow:0 4px 20px 0 rgba(0,0,0,0.06);overflow:hidden;will-change:transform,opacity;transition:transform .3s ease,opacity .3s ease;touch-action:pan-y;z-index:2;cursor:grab}
  47.         .job-card-main:active{cursor:grabbing}
  48.         .job-card-main.dragging{transition:none}
  49.         .job-card-main.swipe-out-right{transform:translateX(120%) rotate(8deg);opacity:0}
  50.         .job-card-main.swipe-out-left{transform:translateX(-120%) rotate(-8deg);opacity:0}
  51.         .job-card-main.swipe-in{animation:swipeIn .35s ease forwards}
  52.         @keyframes swipeIn{0%{transform:translateX(0) scale(.95);opacity:.5}100%{transform:translateX(0) scale(1);opacity:1}}
  53.         /* ─── Skeleton loader (pendant chargement AJAX) ─── */
  54.         .skeleton{padding:24px}
  55.         @media(max-width:480px){.skeleton{padding:18px}}
  56.         .skeleton-line{background:linear-gradient(90deg,#F3F4F6 0%,#E5E7EB 50%,#F3F4F6 100%);background-size:200% 100%;border-radius:6px;animation:skeletonShimmer 1.4s ease-in-out infinite}
  57.         @keyframes skeletonShimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
  58.         .skeleton-pills{display:flex;gap:6px;margin-bottom:16px}
  59.         .skeleton-pill{height:22px;width:70px;border-radius:100px;background:linear-gradient(90deg,#F3F4F6 0%,#E5E7EB 50%,#F3F4F6 100%);background-size:200% 100%;animation:skeletonShimmer 1.4s ease-in-out infinite}
  60.         .skeleton-pill.s-w90{width:90px}
  61.         .skeleton-title{height:32px;width:75%;margin-bottom:8px;border-radius:8px}
  62.         .skeleton-title-2{height:32px;width:50%;margin-bottom:18px;border-radius:8px}
  63.         .skeleton-meta{display:flex;gap:6px;margin-bottom:24px;flex-wrap:wrap}
  64.         .skeleton-meta-item{height:24px;width:80px;border-radius:100px;background:linear-gradient(90deg,#F3F4F6 0%,#E5E7EB 50%,#F3F4F6 100%);background-size:200% 100%;animation:skeletonShimmer 1.4s ease-in-out infinite}
  65.         .skeleton-section{padding:20px 24px;border-top:1px solid #F3F4F6}
  66.         @media(max-width:480px){.skeleton-section{padding:16px 18px}}
  67.         .skeleton-section-head{display:flex;align-items:center;gap:10px;margin-bottom:14px}
  68.         .skeleton-section-icon{width:32px;height:32px;border-radius:10px;background:linear-gradient(90deg,#F3F4F6 0%,#E5E7EB 50%,#F3F4F6 100%);background-size:200% 100%;animation:skeletonShimmer 1.4s ease-in-out infinite;flex-shrink:0}
  69.         .skeleton-section-title{height:18px;width:140px;border-radius:6px}
  70.         .skeleton-line-text{height:13px;margin-bottom:8px}
  71.         .skeleton-w-full{width:100%}
  72.         .skeleton-w-90{width:90%}
  73.         .skeleton-w-75{width:75%}
  74.         .skeleton-w-60{width:60%}
  75.         /* Pulse au chargement initial (1 fois) */
  76.         .job-card-main.intro-pulse{animation:introPulse 1.5s ease-out forwards}
  77.         @keyframes introPulse{
  78.             0%{transform:translateX(0)}
  79.             20%{transform:translateX(20px) rotate(2deg)}
  80.             40%{transform:translateX(-15px) rotate(-1.5deg)}
  81.             60%{transform:translateX(8px) rotate(.8deg)}
  82.             80%{transform:translateX(-3px) rotate(-.3deg)}
  83.             100%{transform:translateX(0)}
  84.         }
  85.         /* Indicateurs swipe (apparaissent pendant le drag) */
  86.         .swipe-indicator{position:absolute;top:50%;padding:12px 20px;border-radius:12px;font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;border:3px solid;opacity:0;transition:opacity .15s ease;pointer-events:none;z-index:10;transform:translateY(-50%) rotate(-15deg)}
  87.         .swipe-indicator-prev{left:20px;color:#16A34A;border-color:#16A34A;background:rgba(220,252,231,.95)}
  88.         .swipe-indicator-next{right:20px;color:#6C3AED;border-color:#6C3AED;background:rgba(245,243,255,.95);transform:translateY(-50%) rotate(15deg)}
  89.         /* Header card minimaliste : titre direct */
  90.         .job-card-head{padding:24px 24px 16px}
  91.         @media(max-width:480px){.job-card-head{padding:18px 18px 14px}}
  92.         .job-card-title{font-size:24px;font-weight:800;color:#1E1B2E;line-height:1.2;letter-spacing:-0.02em;margin:0 0 12px}
  93.         @media(min-width:768px){.job-card-title{font-size:28px}}
  94.         @media(max-width:480px){.job-card-title{font-size:22px}}
  95.         .job-card-meta{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:0}
  96.         .job-card-meta-item{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:100px;background:#F3F4F6;font-size:12px;color:#4B5563;font-weight:500}
  97.         .job-card-meta-item svg{width:12px;height:12px;flex-shrink:0;color:#6B7280}
  98.         .job-card-meta-salary{background:#F5F3FF;color:var(--theme-color,#6C3AED);font-weight:600}
  99.         .job-card-meta-salary svg{color:currentColor}
  100.         .job-card-meta-active{background:#ECFDF5;color:#059669;font-weight:600}
  101.         .job-card-meta-external{background:#FFFBEB;color:#92400E;font-weight:600}
  102.         .job-card-active-dot{display:inline-block;width:5px;height:5px;border-radius:50%;background:#10B981;box-shadow:0 0 6px rgba(16,185,129,.5);flex-shrink:0}
  103.         /* Sections */
  104.         .job-card-section{padding:20px 24px;border-top:1px solid #F3F4F6}
  105.         @media(max-width:480px){.job-card-section{padding:16px 18px}}
  106.         .job-card-section-title{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:700;color:#1E1B2E;margin:0 0 12px;letter-spacing:-0.01em;scroll-margin-top:80px}
  107.         .job-card-section-icon{width:32px;height:32px;border-radius:10px;background:#F5F3FF;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;color:var(--theme-color,#6C3AED)}
  108.         .job-card-section-icon svg{width:16px;height:16px}
  109.         .job-card-section-text{font-size:14px;line-height:1.7;color:#374151;margin:0}
  110.         /* Footer card */
  111.         .job-card-actions{display:flex;align-items:center;gap:8px;padding:16px 24px;border-top:1px solid #F3F4F6;background:#FAFAFA}
  112.         @media(max-width:480px){.job-card-actions{padding:12px 16px;flex-wrap:wrap}}
  113.         .job-action-btn{display:flex;align-items:center;justify-content:center;gap:6px;padding:10px 16px;border-radius:10px;background:#fff;border:1px solid #E5E7EB;font-size:13px;font-weight:600;color:#374151;cursor:pointer;text-decoration:none;transition:border-color .15s,transform .15s;font-family:inherit}
  114.         .job-action-btn:hover{border-color:var(--theme-color,#6C3AED);color:var(--theme-color,#6C3AED);transform:translateY(-1px)}
  115.         .job-action-btn svg{width:14px;height:14px}
  116.         .job-action-btn-primary{background:var(--theme-color,#6C3AED);color:#fff;border-color:var(--theme-color,#6C3AED);flex:1;justify-content:center}
  117.         .job-action-btn-primary:hover{background:#5B21B6;color:#fff;border-color:#5B21B6}
  118.         .job-action-btn-locked{background:#fff !important;color:var(--theme-color,#6C3AED) !important;border:1.5px dashed rgba(108,58,237,.35) !important;position:relative}
  119.         .job-action-btn-locked:hover{background:#F5F3FF !important;border-color:var(--theme-color,#6C3AED) !important;color:var(--theme-color,#6C3AED) !important}
  120.         /* ─── Bouton like (état + animation) ─── */
  121.         .job-action-like{position:relative;overflow:hidden}
  122.         .job-action-like .job-action-like-icon{transition:transform .25s ease,fill .2s ease}
  123.         .job-action-like.is-liked{background:#16A34A !important;border-color:#16A34A !important;color:#fff !important}
  124.         .job-action-like.is-liked:hover{background:#15803D !important;border-color:#15803D !important;color:#fff !important}
  125.         .job-action-like.is-liked .job-action-like-icon{fill:#fff;transform:scale(1.05)}
  126.         .job-action-like.is-loading{opacity:.65;pointer-events:none}
  127.         .job-action-like.is-loading .job-action-like-icon{animation:likeSpin .8s linear infinite}
  128.         @keyframes likeSpin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
  129.         .job-action-like.like-pulse{animation:likePulse .42s cubic-bezier(.34,1.56,.64,1)}
  130.         @keyframes likePulse{0%{transform:scale(1)}45%{transform:scale(1.08)}100%{transform:scale(1)}}
  131.         /* Card lock (non-connectés) */
  132.         .job-card-locked{padding:24px;background:linear-gradient(135deg,#F5F3FF,#FAFAFA);border-top:1px solid #F3F4F6;text-align:center}
  133.         .job-card-locked-icon{width:48px;height:48px;border-radius:14px;background:#fff;display:inline-flex;align-items:center;justify-content:center;margin-bottom:12px;box-shadow:0 4px 12px rgba(108,58,237,.15);color:var(--theme-color,#6C3AED)}
  134.         .job-card-locked-title{font-size:16px;font-weight:700;color:#1E1B2E;margin:0 0 4px}
  135.         .job-card-locked-text{font-size:13px;color:#6B7280;margin:0 0 14px;line-height:1.5}
  136.         .job-card-locked-buttons{display:flex;gap:8px;justify-content:center;flex-wrap:wrap}
  137.         .job-card-locked-btn{display:inline-flex;align-items:center;gap:5px;padding:10px 18px;border-radius:10px;font-size:13px;font-weight:600;text-decoration:none;cursor:pointer;transition:all .15s;font-family:inherit;border:none}
  138.         .job-card-locked-btn-primary{background:var(--theme-color,#6C3AED);color:#fff}
  139.         .job-card-locked-btn-primary:hover{background:#5B21B6;color:#fff;transform:translateY(-1px)}
  140.         .job-card-locked-btn-secondary{background:#fff;color:#1E1B2E;border:1.5px solid #E5E7EB}
  141.         .job-card-locked-btn-secondary:hover{border-color:var(--theme-color,#6C3AED);color:var(--theme-color,#6C3AED)}
  142.         /* ─── Card promo (blanche, logo centré, bullet list, boutons empilés) ─── */
  143.         .promo-card{background:#fff;border-radius:16px;padding:32px 28px;text-align:center;box-shadow:0 4px 20px 0 rgba(0,0,0,0.06)}
  144.         @media(max-width:480px){.promo-card{padding:24px 20px}}
  145.         .promo-card-logo{width:80px !important;height:80px !important;border-radius:20px !important;display:inline-flex !important;align-items:center !important;justify-content:center !important;margin:0 auto 18px !important;overflow:hidden !important;padding:14px !important;box-sizing:border-box !important;box-shadow:0 4px 16px rgba(108,58,237,.12)}
  146.         .promo-card-logo img{width:100% !important;height:100% !important;object-fit:contain !important;display:block !important;border-radius:0 !important;border:0 !important;box-shadow:none !important;background:transparent !important}
  147.         .promo-card-title{font-size:22px;font-weight:800;color:#1E1B2E;margin:0 0 10px;line-height:1.2;letter-spacing:-0.01em}
  148.         @media(min-width:768px){.promo-card-title{font-size:26px}}
  149.         .promo-card-text{font-size:14px;line-height:1.6;color:#6B7280;margin:0 0 22px;max-width:420px;margin-left:auto;margin-right:auto}
  150.         .promo-card-bullets{list-style:none;padding:0;margin:0 0 24px;text-align:left;max-width:380px;margin-left:auto;margin-right:auto;display:flex;flex-direction:column;gap:10px}
  151.         .promo-card-bullet{display:flex;align-items:flex-start;gap:10px;font-size:14px;color:#374151;line-height:1.5}
  152.         .promo-card-bullet-icon{width:22px;height:22px;border-radius:50%;background:#F5F3FF;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}
  153.         .promo-card-bullet-icon svg{width:12px;height:12px;color:var(--theme-color,#6C3AED)}
  154.         .promo-card-bullet-text{flex:1}
  155.         .promo-card-bullet-text strong{color:#1E1B2E;font-weight:700}
  156.         .promo-card-buttons{display:flex;flex-direction:column;gap:10px;max-width:340px;margin-left:auto;margin-right:auto}
  157.         .promo-card-btn{display:flex;align-items:center;justify-content:center;gap:8px;padding:12px 18px;border-radius:10px;font-size:14px;font-weight:600;text-decoration:none;cursor:pointer;font-family:inherit;transition:transform .15s,background .15s,border-color .15s;width:100%;border:none}
  158.         .promo-card-btn:hover{transform:translateY(-1px)}
  159.         .promo-card-btn svg{width:16px;height:16px;flex-shrink:0}
  160.         .promo-card-btn-pastel{background:#F5F3FF;color:var(--theme-color,#6C3AED)}
  161.         .promo-card-btn-pastel:hover{background:#EDE9FE;color:var(--theme-color,#6C3AED)}
  162.         .promo-card-btn-outline{background:#fff;color:var(--theme-color,#6C3AED);border:1.5px solid #EDE9FE}
  163.         .promo-card-btn-outline:hover{background:#FAFAFA;border-color:#DDD6FE;color:var(--theme-color,#6C3AED)}
  164.         /* Hint clavier sous la pile */
  165.         .job-keyboard-hint{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:14px;font-size:11px;color:#9CA3AF}
  166.         .job-keyboard-hint kbd{padding:2px 6px;background:#F3F4F6;border-radius:4px;font-size:10px;font-family:monospace}
  167.         .job-keyboard-hint-sep{color:#D1D5DB}
  168.         @media(max-width:640px){.job-keyboard-hint{display:none}}
  169.         /* ─── Section "Toutes les offres similaires" en feed (en bas) ─── */
  170.         .similar-section-title{font-size:11px;font-weight:700;color:#9CA3AF;text-transform:uppercase;letter-spacing:.08em;margin:32px 0 12px;padding-left:4px}
  171.         .similar-card{background:#fff;border-radius:14px;padding:14px;box-shadow:0 0 16px 0 rgba(0,0,0,0.04);margin-bottom:10px;display:flex;align-items:center;gap:14px;text-decoration:none;color:inherit;transition:transform .15s,box-shadow .2s}
  172.         .similar-card:hover{transform:translateY(-1px);box-shadow:0 4px 20px rgba(108,58,237,.1);color:inherit}
  173.         .similar-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}
  174.         .similar-card-logo img{width:100%;height:100%;object-fit:cover;border-radius:8px}
  175.         .similar-card-logo svg{width:24px;height:24px;opacity:.85}
  176.         .similar-card-info{flex:1;min-width:0}
  177.         .similar-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}
  178.         .similar-card-meta{font-size:12px;color:#6B7280;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  179.         .similar-card-arrow{flex-shrink:0;color:#9CA3AF;transition:color .15s}
  180.         .similar-card:hover .similar-card-arrow{color:var(--theme-color,#6C3AED)}
  181.         /* Auth modal */
  182.         .auth-modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9998;opacity:0;pointer-events:none;transition:opacity .25s ease}
  183.         .auth-modal-backdrop.visible{opacity:1;pointer-events:auto}
  184.         .auth-modal{position:fixed;left:50%;top:50%;transform:translate(-50%,-45%) scale(.95);background:#fff;border-radius:24px;width:calc(100% - 32px);max-width:440px;max-height:90vh;overflow-y:auto;z-index:9999;box-shadow:0 24px 64px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .25s ease,transform .25s ease}
  185.         .auth-modal.visible{opacity:1;pointer-events:auto;transform:translate(-50%,-50%) scale(1)}
  186.         .auth-modal-header{padding:24px 24px 8px;text-align:center;position:relative}
  187.         .auth-modal-close{position:absolute;top:16px;right:16px;width:36px;height:36px;border-radius:50%;background:#F3F4F6;border:none;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#6B7280}
  188.         .auth-modal-close:hover{background:#E5E7EB}
  189.         .auth-modal-icon{width:64px;height:64px;border-radius:20px;background:#F5F3FF;display:inline-flex;align-items:center;justify-content:center;margin-bottom:16px;color:var(--theme-color,#6C3AED)}
  190.         .auth-modal-title{font-size:22px;font-weight:800;color:#1E1B2E;margin:0 0 8px}
  191.         .auth-modal-subtitle{font-size:14px;color:#6B7280;margin:0;line-height:1.5}
  192.         .auth-modal-body{padding:8px 24px 24px}
  193.         .auth-option{display:flex;align-items:center;gap:14px;padding:16px;border:2px solid #F3F4F6;border-radius:16px;text-decoration:none;color:inherit;transition:all .2s;margin-bottom:10px;background:#fff;cursor:pointer;width:100%;font-family:inherit;text-align:left}
  194.         .auth-option:hover{border-color:rgba(108,58,237,.3);background:#FAFAFA;transform:translateY(-1px);color:inherit}
  195.         .auth-option-icon{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
  196.         .auth-option-icon.ios{background:#000;color:#fff}
  197.         .auth-option-icon.android{background:#34A853;color:#fff}
  198.         .auth-option-icon.web{background:#F5F3FF;color:var(--theme-color,#6C3AED)}
  199.         .auth-option-text{flex:1;min-width:0}
  200.         .auth-option-title{font-size:15px;font-weight:700;color:#1E1B2E;margin:0 0 2px}
  201.         .auth-option-desc{font-size:13px;color:#6B7280;margin:0}
  202.         .auth-option-arrow{color:#9CA3AF;flex-shrink:0}
  203.         .auth-divider{display:flex;align-items:center;gap:12px;margin:16px 0;font-size:12px;color:#9CA3AF;text-transform:uppercase;letter-spacing:.08em;font-weight:600}
  204.         .auth-divider::before,.auth-divider::after{content:'';flex:1;height:1px;background:#E5E7EB}
  205.     </style>
  206. {% endblock css %}
  207. {% block body %}
  208.     {# ═══ Layout 1 colonne centrée (les filtres sont dans la sidebar Sociala gauche) ═══ #}
  209.     <div class="job-layout">
  210.         <main class="job-main">
  211.             {# Variables pile #}
  212.             {% set hasNeighbors = similarJobs is defined and similarJobs is not empty %}
  213.             {% if hasNeighbors %}
  214.                 {% set prevSlug = (similarJobs|last).slug %}
  215.                 {% set nextSlug = (similarJobs|first).slug %}
  216.                 {% set totalOffers = similarJobs|length + 1 %}
  217.             {% endif %}
  218.             {# Bandeau d'info "Vous pouvez swiper" #}
  219.             {% if hasNeighbors %}
  220.                 <div class="swipe-banner">
  221.                     <div class="swipe-banner-info">
  222.                         <span class="swipe-banner-icon">
  223.                             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="5 12 19 12"/><polyline points="5 12 11 6"/><polyline points="5 12 11 18"/><polyline points="19 12 13 6"/><polyline points="19 12 13 18"/></svg>
  224.                         </span>
  225.                         <span class="swipe-banner-text">
  226.                             <strong>{{ similarJobs|length }}</strong> {{ 'job.show.bannerSimilar'|trans }}
  227.                         </span>
  228.                     </div>
  229.                     <span class="swipe-banner-counter" id="bannerCounter">1 / {{ totalOffers }}</span>
  230.                 </div>
  231.             {% endif %}
  232.             {# Boutons navigation prev/next (URL change) #}
  233.             {% if hasNeighbors %}
  234.                 <div class="job-nav">
  235.                     <a href="{% if app.request.locale == 'en' %}{{ path('cvs_application_job_show',{'slug':prevSlug}) }}{% else %}{{ path('locale_cvs_application_job_show',{'_locale':app.request.locale,'slug':prevSlug}) }}{% endif %}"
  236.                        class="job-nav-btn job-nav-btn-prev"
  237.                        id="jobNavPrev"
  238.                        data-slug="{{ prevSlug }}">
  239.                         <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg>
  240.                         <span>{{ 'job.show.previousOffer'|trans }}</span>
  241.                     </a>
  242.                     <a href="{% if app.request.locale == 'en' %}{{ path('cvs_application_job_show',{'slug':nextSlug}) }}{% else %}{{ path('locale_cvs_application_job_show',{'_locale':app.request.locale,'slug':nextSlug}) }}{% endif %}"
  243.                        class="job-nav-btn job-nav-btn-next"
  244.                        id="jobNavNext"
  245.                        data-slug="{{ nextSlug }}">
  246.                         <span>{{ 'job.show.nextOffer'|trans }}</span>
  247.                         <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg>
  248.                     </a>
  249.                 </div>
  250.             {% endif %}
  251.             {# ═══ PILE de cards ═══ #}
  252.             <div class="stack-wrap" id="stackWrap">
  253.                 {# Cards fantômes derrière #}
  254.                 {% if hasNeighbors %}
  255.                     <div class="stack-ghost stack-ghost-2"></div>
  256.                     <div class="stack-ghost stack-ghost-1"></div>
  257.                 {% endif %}
  258.                 {# Card centrale (contenu remplacé en AJAX au swipe) #}
  259.                 <div class="job-card-main {% if hasNeighbors %}intro-pulse{% endif %}" id="jobCardMain" data-current-slug="{{ job.slug }}">
  260.                     <div class="swipe-indicator swipe-indicator-prev" id="swipeIndicatorPrev">{{ 'job.show.previousOffer'|trans }}</div>
  261.                     <div class="swipe-indicator swipe-indicator-next" id="swipeIndicatorNext">{{ 'job.show.nextOffer'|trans }}</div>
  262.                     <div id="jobCardContent">
  263.                         {% include "application/whileresume/application/jobs/_card-content.html.twig" %}
  264.                     </div>
  265.                 </div>
  266.             </div>
  267.             {# Hint clavier + swipe #}
  268.             {% if hasNeighbors %}
  269.                 <div class="job-keyboard-hint">
  270.                     {{ 'job.show.swipeHintFull'|trans|raw }}
  271.                 </div>
  272.             {% endif %}
  273.             {# ═══ Liste des offres similaires en bas ═══ #}
  274.             {% if hasNeighbors %}
  275.                 <h4 class="similar-section-title">{{ 'job.show.similarOffers'|trans }} · {{ similarJobs|length }}</h4>
  276.                 {% for sj in similarJobs %}
  277.                     <a href="{% if app.request.locale == 'en' %}{{ path('cvs_application_job_show',{'slug':sj.slug}) }}{% else %}{{ path('locale_cvs_application_job_show',{'_locale':app.request.locale,'slug':sj.slug}) }}{% endif %}"
  278.                        class="similar-card">
  279.                         <div class="similar-card-logo">
  280.                             {% if sj.enterprise and sj.enterprise.image and sj.enterprise.image.name %}
  281.                                 <img src="{{ vich_uploader_asset(sj.enterprise, 'imageFile') }}" alt="">
  282.                             {% else %}
  283.                                 <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>
  284.                             {% endif %}
  285.                         </div>
  286.                         <div class="similar-card-info">
  287.                             <div class="similar-card-title">{{ sj.jobTitle }}</div>
  288.                             <div class="similar-card-meta">
  289.                                 {{ sj.companyName }}{% if sj.city is not empty %} · {{ sj.city }}{% endif %}{% if sj.employmentType is not empty %} · {{ sj.employmentType }}{% endif %}
  290.                             </div>
  291.                         </div>
  292.                         <span class="similar-card-arrow">
  293.                             <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
  294.                         </span>
  295.                     </a>
  296.                 {% endfor %}
  297.             {% endif %}
  298.         </main>
  299.     </div>
  300.     {# Auth modal #}
  301.     {% if not isCandidateUser %}
  302.         <div class="auth-modal-backdrop" id="authModalBackdrop"></div>
  303.         <div class="auth-modal" id="authModal" role="dialog" aria-modal="true">
  304.             <div class="auth-modal-header">
  305.                 <button class="auth-modal-close" id="authModalClose" aria-label="Close">
  306.                     <svg width="20" height="20" 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>
  307.                 </button>
  308.                 <div class="auth-modal-icon">
  309.                     <svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
  310.                 </div>
  311.                 <h3 class="auth-modal-title">{{ 'job.show.authModalTitle'|trans }}</h3>
  312.                 <p class="auth-modal-subtitle">{{ 'job.show.authModalSubtitle'|trans }}</p>
  313.             </div>
  314.             <div class="auth-modal-body">
  315.                 <a href="{{ ios }}" target="_blank" rel="noopener" class="auth-option">
  316.                     <div class="auth-option-icon ios">
  317.                         <svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/></svg>
  318.                     </div>
  319.                     <div class="auth-option-text">
  320.                         <p class="auth-option-title">{{ 'job.show.authIosTitle'|trans }}</p>
  321.                         <p class="auth-option-desc">{{ 'job.show.authIosDesc'|trans }}</p>
  322.                     </div>
  323.                     <svg class="auth-option-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
  324.                 </a>
  325.                 <a href="{{ android }}" target="_blank" rel="noopener" class="auth-option">
  326.                     <div class="auth-option-icon android">
  327.                         <svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M3 20.5V3.5c0-.41.34-.75.75-.75.18 0 .35.07.49.18l13.42 8.5a.75.75 0 010 1.27L4.24 21.07a.75.75 0 01-1.24-.57z"/></svg>
  328.                     </div>
  329.                     <div class="auth-option-text">
  330.                         <p class="auth-option-title">{{ 'job.show.authAndroidTitle'|trans }}</p>
  331.                         <p class="auth-option-desc">{{ 'job.show.authAndroidDesc'|trans }}</p>
  332.                     </div>
  333.                     <svg class="auth-option-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
  334.                 </a>
  335.                 <div class="auth-divider">{{ 'job.show.orDivider'|trans }}</div>
  336.                 <a href="{% if app.request.locale == 'en' %}{{ path('app_login') }}{% else %}{{ path('locale_app_login',{'_locale':app.request.locale}) }}{% endif %}" class="auth-option">
  337.                     <div class="auth-option-icon web">
  338.                         <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
  339.                     </div>
  340.                     <div class="auth-option-text">
  341.                         <p class="auth-option-title">{{ 'job.show.authWebTitle'|trans }}</p>
  342.                         <p class="auth-option-desc">{{ 'job.show.authWebDesc'|trans }}</p>
  343.                     </div>
  344.                     <svg class="auth-option-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
  345.                 </a>
  346.             </div>
  347.         </div>
  348.     {% endif %}
  349. {% endblock body %}
  350. {% block footerjs %}
  351.     {# Re-déclaration variables (pas accessible cross-block) #}
  352.     {% set hasNeighbors = similarJobs is defined and similarJobs is not empty %}
  353.     <script>
  354.         (function(){
  355.             'use strict';
  356.             try {
  357.                 console.log('[whr-job] init script');
  358.                 var IS_AUTHENTICATED = {{ app.user is not null ? 'true' : 'false' }};
  359.                 var IS_CANDIDATE = {{ (app.user is not null and app.user.candidate is not null) ? 'true' : 'false' }};
  360.                 var LOCALE = '{{ app.request.locale }}';
  361.                 var IOS_URL = '{{ ios|default('')|escape('js') }}';
  362.                 var ANDROID_URL = '{{ android|default('')|escape('js') }}';
  363.                 // Fallbacks vers la home si les liens stores ne sont pas configurés
  364.                 if(!IOS_URL) IOS_URL = '{{ ios }}';
  365.                 if(!ANDROID_URL) ANDROID_URL = '{{ android }}';
  366.                 {% if app.request.locale == "fr" %}
  367.                 var SEARCH_API_URL = '{{ path("locale_cvs_application_jobs_api_search",{"_locale":app.request.locale}) }}';
  368.                 var AJAX_SHOW_URL_TEMPLATE = '{{ path("locale_cvs_application_job_ajax",{"_locale":app.request.locale,"slug":"__SLUG__"}) }}';
  369.                 var REGISTER_URL = '{{ path("whileresume_resume_fr") }}';
  370.                 var LOGIN_URL = '{{ path("locale_app_login",{"_locale":app.request.locale}) }}';
  371.                 {% else %}
  372.                 var SEARCH_API_URL = '{{ path("cvs_application_jobs_api_search",{"_locale":app.request.locale}) }}';
  373.                 var AJAX_SHOW_URL_TEMPLATE = '{{ path("cvs_application_job_ajax",{"_locale":app.request.locale,"slug":"__SLUG__"}) }}';
  374.                 var REGISTER_URL = '{{ path("whileresume_resume_en") }}';
  375.                 var LOGIN_URL = '{{ path("app_login") }}';
  376.                 {% endif %}
  377.                 // Liste cyclique des slugs (offre courante + similaires)
  378.                 var SLUGS = [
  379.                     '{{ job.slug }}'
  380.                     {% if hasNeighbors %}{% for sj in similarJobs %},
  381.                     '{{ sj.slug }}'{% endfor %}{% endif %}
  382.                 ];
  383.                 var TOTAL = SLUGS.length;
  384.                 var currentIndex = 0;
  385.                 var swipeCount = 0;        // nombre de cards (vraies offres) chargées via swipe
  386.                 var PROMO_EVERY_N_SWIPES = 3; // afficher la promo toutes les N navigations (cyclique)
  387.                 // ═══ Auth modal ═══
  388.                 var authModal = document.getElementById('authModal');
  389.                 var authBackdrop = document.getElementById('authModalBackdrop');
  390.                 var authClose = document.getElementById('authModalClose');
  391.                 function openAuthModal(){
  392.                     if(!authModal) return;
  393.                     authModal.classList.add('visible');
  394.                     authBackdrop.classList.add('visible');
  395.                     document.body.style.overflow = 'hidden';
  396.                 }
  397.                 function closeAuthModal(){
  398.                     if(!authModal) return;
  399.                     authModal.classList.remove('visible');
  400.                     authBackdrop.classList.remove('visible');
  401.                     document.body.style.overflow = '';
  402.                 }
  403.                 if(authClose) authClose.addEventListener('click', closeAuthModal);
  404.                 if(authBackdrop) authBackdrop.addEventListener('click', closeAuthModal);
  405.                 // Labels du bouton like (utilisés par applyLikeState et buildJobHtml)
  406.                 var LIKE_LABEL_DEFAULT = {{ 'job.show.imInterested'|trans|json_encode|raw }};
  407.                 var LIKE_LABEL_LIKED  = {{ 'job.show.interested'|trans|json_encode|raw }};
  408.                 function applyLikeState(btn, liked){
  409.                     if(!btn) return;
  410.                     btn.dataset.liked = liked ? '1' : '0';
  411.                     btn.classList.toggle('is-liked', liked);
  412.                     var label = btn.querySelector('.job-action-like-label');
  413.                     if(label){
  414.                         label.textContent = liked ? LIKE_LABEL_LIKED : LIKE_LABEL_DEFAULT;
  415.                     }
  416.                     // Animation pulse au like (pas à l'unlike)
  417.                     if(liked){
  418.                         btn.classList.remove('like-pulse');
  419.                         // Force reflow pour relancer l'animation
  420.                         void btn.offsetWidth;
  421.                         btn.classList.add('like-pulse');
  422.                     }
  423.                 }
  424.                 function bindLikeHandlers(){
  425.                     var apply = document.getElementById('sideCtaApply');
  426.                     var save = document.getElementById('sideCtaSave');
  427.                     var cardLike = document.getElementById('cardLikeBtn');
  428.                     // Boutons sidebar (sideCtaApply, sideCtaSave) : modal d'auth si non-candidat
  429.                     [apply, save].forEach(function(el){
  430.                         if(!el) return;
  431.                         if(el.dataset.likeBound) return;
  432.                         el.dataset.likeBound = '1';
  433.                         el.addEventListener('click', function(e){
  434.                             if(!IS_CANDIDATE){
  435.                                 e.preventDefault();
  436.                                 openAuthModal();
  437.                             }
  438.                         });
  439.                     });
  440.                     // Bouton card : toggle AJAX (like / unlike)
  441.                     if(cardLike && !cardLike.dataset.likeBound){
  442.                         cardLike.dataset.likeBound = '1';
  443.                         cardLike.addEventListener('click', function(e){
  444.                             // Bouton "locked" → modal d'auth
  445.                             if(cardLike.dataset.locked === '1' || !IS_CANDIDATE){
  446.                                 e.preventDefault();
  447.                                 openAuthModal();
  448.                                 return;
  449.                             }
  450.                             // Pas de navigation : on gère en AJAX
  451.                             e.preventDefault();
  452.                             // Anti double-clic
  453.                             if(cardLike.dataset.likeBusy === '1') return;
  454.                             cardLike.dataset.likeBusy = '1';
  455.                             cardLike.classList.add('is-loading');
  456.                             var url = cardLike.getAttribute('href');
  457.                             fetch(url, {
  458.                                 method: 'GET',
  459.                                 headers: {
  460.                                     'X-Requested-With': 'XMLHttpRequest',
  461.                                     'Accept': 'application/json'
  462.                                 },
  463.                                 credentials: 'same-origin'
  464.                             })
  465.                                 .then(function(r){
  466.                                     if(r.status === 401){
  467.                                         openAuthModal();
  468.                                         return null;
  469.                                     }
  470.                                     if(!r.ok) throw new Error('HTTP ' + r.status);
  471.                                     return r.json();
  472.                                 })
  473.                                 .then(function(data){
  474.                                     if(!data || !data.success) return;
  475.                                     applyLikeState(cardLike, !!data.liked);
  476.                                     // Synchronise le cache des voisins prefetchées :
  477.                                     // sans ça, un swipe vers cette card ré-utiliserait
  478.                                     // l'ancien isLiked figé lors du prefetch initial.
  479.                                     var slugChanged = cardLike.dataset.jobSlug || jobCard.dataset.currentSlug;
  480.                                     if(slugChanged && cardCache[slugChanged]){
  481.                                         cardCache[slugChanged].isLiked = !!data.liked;
  482.                                     }
  483.                                 })
  484.                                 .catch(function(err){
  485.                                     console.error('Like AJAX error', err);
  486.                                 })
  487.                                 .then(function(){
  488.                                     cardLike.dataset.likeBusy = '0';
  489.                                     cardLike.classList.remove('is-loading');
  490.                                 });
  491.                         });
  492.                     }
  493.                 }
  494.                 bindLikeHandlers();
  495.                 // ═══ Pile : navigation par AJAX (swipe) vs URL (boutons) ═══
  496.                 var jobCard = document.getElementById('jobCardMain');
  497.                 var jobCardContent = document.getElementById('jobCardContent');
  498.                 var indicatorPrev = document.getElementById('swipeIndicatorPrev');
  499.                 var indicatorNext = document.getElementById('swipeIndicatorNext');
  500.                 var bannerCounter = document.getElementById('bannerCounter');
  501.                 function getSlugAt(offset){
  502.                     var idx = (currentIndex + offset + TOTAL) % TOTAL;
  503.                     return SLUGS[idx];
  504.                 }
  505.                 function updateCounter(){
  506.                     if(bannerCounter) bannerCounter.textContent = (currentIndex + 1) + ' / ' + TOTAL;
  507.                 }
  508.                 function renderSkeleton(){
  509.                     return '' +
  510.                         '<div class="skeleton">' +
  511.                         '<div class="skeleton-pills">' +
  512.                         '<div class="skeleton-pill"></div>' +
  513.                         '<div class="skeleton-pill s-w90"></div>' +
  514.                         '</div>' +
  515.                         '<div class="skeleton-line skeleton-title"></div>' +
  516.                         '<div class="skeleton-line skeleton-title-2"></div>' +
  517.                         '<div class="skeleton-meta">' +
  518.                         '<div class="skeleton-meta-item"></div>' +
  519.                         '<div class="skeleton-meta-item"></div>' +
  520.                         '<div class="skeleton-meta-item" style="width:100px;"></div>' +
  521.                         '</div>' +
  522.                         '</div>' +
  523.                         '<div class="skeleton-section">' +
  524.                         '<div class="skeleton-section-head">' +
  525.                         '<div class="skeleton-section-icon"></div>' +
  526.                         '<div class="skeleton-line skeleton-section-title"></div>' +
  527.                         '</div>' +
  528.                         '<div class="skeleton-line skeleton-line-text skeleton-w-full"></div>' +
  529.                         '<div class="skeleton-line skeleton-line-text skeleton-w-90"></div>' +
  530.                         '<div class="skeleton-line skeleton-line-text skeleton-w-full"></div>' +
  531.                         '<div class="skeleton-line skeleton-line-text skeleton-w-75"></div>' +
  532.                         '<div class="skeleton-line skeleton-line-text skeleton-w-60"></div>' +
  533.                         '</div>' +
  534.                         '<div class="skeleton-section">' +
  535.                         '<div class="skeleton-section-head">' +
  536.                         '<div class="skeleton-section-icon"></div>' +
  537.                         '<div class="skeleton-line skeleton-section-title"></div>' +
  538.                         '</div>' +
  539.                         '<div class="skeleton-line skeleton-line-text skeleton-w-full"></div>' +
  540.                         '<div class="skeleton-line skeleton-line-text skeleton-w-90"></div>' +
  541.                         '<div class="skeleton-line skeleton-line-text skeleton-w-75"></div>' +
  542.                         '</div>';
  543.                 }
  544.                 function renderPromoCard(){
  545.                     var checkSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
  546.                     return '' +
  547.                         '<div class="promo-card">' +
  548.                         '<div class="promo-card-logo">' +
  549.                         '<img src="/uploads/favicon.png" alt="Whileresume">' +
  550.                         '</div>' +
  551.                         '<h2 class="promo-card-title">{{ 'job.show.promoTitle'|trans|escape('js') }}</h2>' +
  552.                         '<p class="promo-card-text">{{ 'job.show.promoText'|trans|escape('js') }}</p>' +
  553.                         '<ul class="promo-card-bullets">' +
  554.                         '<li class="promo-card-bullet">' +
  555.                         '<span class="promo-card-bullet-icon">' + checkSvg + '</span>' +
  556.                         '<span class="promo-card-bullet-text">{{ 'job.show.promoBullet1'|trans|escape('js') }}</span>' +
  557.                         '</li>' +
  558.                         '<li class="promo-card-bullet">' +
  559.                         '<span class="promo-card-bullet-icon">' + checkSvg + '</span>' +
  560.                         '<span class="promo-card-bullet-text">{{ 'job.show.promoBullet2'|trans|escape('js') }}</span>' +
  561.                         '</li>' +
  562.                         '<li class="promo-card-bullet">' +
  563.                         '<span class="promo-card-bullet-icon">' + checkSvg + '</span>' +
  564.                         '<span class="promo-card-bullet-text">{{ 'job.show.promoBullet3'|trans|escape('js') }}</span>' +
  565.                         '</li>' +
  566.                         '</ul>' +
  567.                         '<div class="promo-card-buttons">' +
  568.                         '<a href="' + REGISTER_URL + '" class="promo-card-btn promo-card-btn-pastel">' +
  569.                         '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>' +
  570.                         '{{ 'job.show.promoBtnSignup'|trans|escape('js') }}' +
  571.                         '</a>' +
  572.                         '<a href="' + LOGIN_URL + '" class="promo-card-btn promo-card-btn-outline">' +
  573.                         '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>' +
  574.                         '{{ 'job.show.promoBtnSignin'|trans|escape('js') }}' +
  575.                         '</a>' +
  576.                         '<a href="' + IOS_URL + '" target="_blank" rel="noopener" class="promo-card-btn promo-card-btn-pastel">' +
  577.                         '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/></svg>' +
  578.                         '{{ 'job.show.promoBtnIos'|trans|escape('js') }}' +
  579.                         '</a>' +
  580.                         '<a href="' + ANDROID_URL + '" target="_blank" rel="noopener" class="promo-card-btn promo-card-btn-pastel">' +
  581.                         '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 20.5V3.5c0-.41.34-.75.75-.75.18 0 .35.07.49.18l13.42 8.5a.75.75 0 010 1.27L4.24 21.07a.75.75 0 01-1.24-.57z"/></svg>' +
  582.                         '{{ 'job.show.promoBtnAndroid'|trans|escape('js') }}' +
  583.                         '</a>' +
  584.                         '</div>' +
  585.                         '</div>';
  586.                 }
  587.                 function buildJobHtml(data){
  588.                     if(!data) return '';
  589.                     var meta = '';
  590.                     // Pilule "Offre active" en premier dans les meta
  591.                     meta += '<span class="job-card-meta-item job-card-meta-active"><span class="job-card-active-dot"></span>{{ 'job.show.activeOffer'|trans|escape('js') }}</span>';
  592.                     if(data.verification === 0 || data.verification === false){
  593.                         meta += '<span class="job-card-meta-item job-card-meta-external">⚡ {{ 'job.show.externalSource'|trans|escape('js') }}</span>';
  594.                     }
  595.                     if(data.city){
  596.                         meta += '<span class="job-card-meta-item"><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>' + escapeHtml(data.city) + (data.country ? ', ' + escapeHtml(data.country) : '') + '</span>';
  597.                     }
  598.                     if(data.employmentType){
  599.                         meta += '<span class="job-card-meta-item"><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>' + escapeHtml(data.employmentType) + '</span>';
  600.                     }
  601.                     if(data.remoteWork){
  602.                         meta += '<span class="job-card-meta-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>' + escapeHtml(data.remoteWork) + '</span>';
  603.                     }
  604.                     if(data.salaryPeriod){
  605.                         meta += '<span class="job-card-meta-item job-card-meta-salary"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>' + data.salaryMin + '–' + data.salaryMax + ' ' + (data.devise || '') + '/' + escapeHtml(data.salaryPeriod) + '</span>';
  606.                     }
  607.                     var html = '<div class="job-card-head">' +
  608.                         '<h1 class="job-card-title">' + escapeHtml(data.title) + '</h1>' +
  609.                         '<div class="job-card-meta">' + meta + '</div>' +
  610.                         '</div>';
  611.                     if(data.jobSummary){
  612.                         html += '<div class="job-card-section" id="section-presentation">' +
  613.                             '<div class="job-card-section-title"><span class="job-card-section-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span><span>{{ 'job.show.sectionPresentation'|trans|escape('js') }}</span></div>' +
  614.                             '<p class="job-card-section-text">' + nl2brEscape(data.jobSummary) + '</p>' +
  615.                             '</div>';
  616.                     }
  617.                     if(data.locked){
  618.                         html += '<div class="job-card-locked">' +
  619.                             '<div class="job-card-locked-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg></div>' +
  620.                             '<div class="job-card-locked-title">{{ 'job.show.unlockTitle'|trans|escape('js') }}</div>' +
  621.                             '<div class="job-card-locked-text">{{ 'job.show.unlockText'|trans|escape('js') }}</div>' +
  622.                             '<div class="job-card-locked-buttons">' +
  623.                             '<a href="' + REGISTER_URL + '" class="job-card-locked-btn job-card-locked-btn-primary">{{ 'job.show.createProfileFree'|trans|escape('js') }}</a>' +
  624.                             '</div>' +
  625.                             '</div>';
  626.                     } else {
  627.                         if(data.keyResponsabilities){
  628.                             html += '<div class="job-card-section"><div class="job-card-section-title"><span class="job-card-section-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span><span>{{ 'job.show.sectionMissions'|trans|escape('js') }}</span></div><p class="job-card-section-text">' + nl2brEscape(data.keyResponsabilities) + '</p></div>';
  629.                         }
  630.                         if(data.requirements){
  631.                             html += '<div class="job-card-section"><div class="job-card-section-title"><span class="job-card-section-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span><span>{{ 'job.show.sectionProfile'|trans|escape('js') }}</span></div><p class="job-card-section-text">' + nl2brEscape(data.requirements) + '</p></div>';
  632.                         }
  633.                         if(data.benefits){
  634.                             html += '<div class="job-card-section"><div class="job-card-section-title"><span class="job-card-section-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span><span>{{ 'job.show.sectionBenefits'|trans|escape('js') }}</span></div><p class="job-card-section-text">' + nl2brEscape(data.benefits) + '</p></div>';
  635.                         }
  636.                     }
  637.                     // ═══ BOUTONS D'ACTION ═══
  638.                     // Logique :
  639.                     // - Offre indexée (websearch=1) + website : 'Postuler en externe' = bouton PRINCIPAL violet
  640.                     //                                            'Sauvegarder' = bouton SECONDAIRE (heart)
  641.                     // - Offre interne : 'Je suis intéressé' reste le bouton principal (heart)
  642.                     // Le bouton 'Sauvegarder' demande la connexion si user anonyme.
  643.                     var isIndexed = (data.websearch == 1 && data.website);
  644.                     var actionsHtml = '<div class="job-card-actions">';
  645.                     if(isIndexed){
  646.                         // 1. Bouton PRINCIPAL : Postuler en externe (toujours dispo, même anonyme)
  647.                         actionsHtml +=
  648.                             '<a href="' + escapeHtml(data.website) + '" target="_blank" rel="noopener" class="job-action-btn job-action-btn-primary">' +
  649.                             '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>' +
  650.                             '{{ 'job.show.applyNow'|trans|escape('js') }}' +
  651.                             '</a>';
  652.                         // 2. Bouton SECONDAIRE : Sauvegarder (heart, en favori)
  653.                         if(data.likeUrl){
  654.                             actionsHtml +=
  655.                                 '<a href="' + data.likeUrl + '" class="job-action-btn">' +
  656.                                 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>' +
  657.                                 '{{ 'job.show.save'|trans|escape('js') }}' +
  658.                                 '</a>';
  659.                         } else {
  660.                             actionsHtml +=
  661.                                 '<button class="job-action-btn job-action-btn-locked" id="cardLikeBtn" type="button" data-locked="1">' +
  662.                                 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>' +
  663.                                 '{{ 'job.show.save'|trans|escape('js') }}' +
  664.                                 '</button>';
  665.                         }
  666.                     } else {
  667.                         // Offre interne : 'Je suis intéressé' reste le bouton principal
  668.                         if(data.likeUrl){
  669.                             var liked = !!data.isLiked;
  670.                             var likeLabel = liked ? LIKE_LABEL_LIKED : LIKE_LABEL_DEFAULT;
  671.                             actionsHtml +=
  672.                                 '<a href="' + data.likeUrl + '" ' +
  673.                                 'class="job-action-btn job-action-btn-primary job-action-like' + (liked ? ' is-liked' : '') + '" ' +
  674.                                 'id="cardLikeBtn" ' +
  675.                                 'data-liked="' + (liked ? '1' : '0') + '" ' +
  676.                                 'data-job-slug="' + escapeHtml(data.slug) + '">' +
  677.                                 '<svg class="job-action-like-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>' +
  678.                                 '<span class="job-action-like-label">' + escapeHtml(likeLabel) + '</span>' +
  679.                                 '</a>';
  680.                         } else {
  681.                             actionsHtml +=
  682.                                 '<button class="job-action-btn job-action-btn-primary job-action-btn-locked" id="cardLikeBtn" type="button" data-locked="1">' +
  683.                                 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>' +
  684.                                 '{{ 'job.show.imInterestedLocked'|trans|escape('js') }}' +
  685.                                 '</button>';
  686.                         }
  687.                     }
  688.                     actionsHtml += '</div>';
  689.                     html += actionsHtml;
  690.                     return html;
  691.                 }
  692.                 function escapeHtml(s){
  693.                     if(s == null) return '';
  694.                     return String(s).replace(/[&<>"']/g, function(c){
  695.                         return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
  696.                     });
  697.                 }
  698.                 function nl2brEscape(s){
  699.                     if(s == null) return '';
  700.                     // Décoder les \n littéraux puis échapper et remplacer en <br>
  701.                     var t = String(s).replace(/\\n/g, '\n');
  702.                     return escapeHtml(t).replace(/\n/g, '<br>');
  703.                 }
  704.                 // ═══ Cache des cards déjà chargées ═══
  705.                 var cardCache = {};
  706.                 // On préload la card courante depuis le DOM (vu qu'elle est déjà rendue)
  707.                 // Pas nécessaire — on ne la rechargera jamais via AJAX vu qu'on est dessus.
  708.                 function loadCardAjax(slug, direction){
  709.                     if(!slug) return Promise.resolve(null);
  710.                     if(cardCache[slug]) return Promise.resolve(cardCache[slug]);
  711.                     var url = AJAX_SHOW_URL_TEMPLATE.replace('__SLUG__', encodeURIComponent(slug));
  712.                     return fetch(url, {headers:{'Accept':'application/json'}})
  713.                         .then(function(r){ return r.ok ? r.json() : null; })
  714.                         .then(function(data){
  715.                             if(data) cardCache[slug] = data;
  716.                             return data;
  717.                         })
  718.                         .catch(function(){ return null; });
  719.                 }
  720.                 // Précharger les voisins de la card courante en background
  721.                 function prefetchNeighbors(){
  722.                     if(TOTAL < 2) return;
  723.                     var prevIdx = (currentIndex - 1 + TOTAL) % TOTAL;
  724.                     var nextIdx = (currentIndex + 1) % TOTAL;
  725.                     if(currentIndex >= 0){
  726.                         loadCardAjax(SLUGS[prevIdx]);
  727.                         loadCardAjax(SLUGS[nextIdx]);
  728.                     }
  729.                 }
  730.                 // Lock pour éviter les swipes en parallèle pendant l'animation
  731.                 var isAnimating = false;
  732.                 function navigateAjax(direction){
  733.                     if(isAnimating) return;
  734.                     isAnimating = true;
  735.                     jobCard.classList.remove('intro-pulse');
  736.                     var leavingPromo = (currentIndex === -1);
  737.                     // Toujours incrémenter à chaque swipe valide (sauf sortie de promo)
  738.                     if(!leavingPromo){
  739.                         swipeCount = swipeCount + 1;
  740.                     }
  741.                     // Promo cyclique : tous les N swipes (peu importe direction)
  742.                     var goingToPromo = (!leavingPromo && swipeCount > 0 && swipeCount % PROMO_EVERY_N_SWIPES === 0);
  743.                     console.log('[whr-job] swipe direction=' + direction + ' currentIndex=' + currentIndex + ' swipeCount=' + swipeCount + ' goingToPromo=' + goingToPromo + ' leavingPromo=' + leavingPromo);
  744.                     // Sortir de la promo
  745.                     if(leavingPromo){
  746.                         var lastIdx = parseInt(jobCard.dataset.lastJobIndex || '0', 10);
  747.                         var targetIdx = (direction > 0)
  748.                             ? (lastIdx + 1 + TOTAL) % TOTAL
  749.                             : lastIdx;
  750.                         var targetSlug = SLUGS[targetIdx];
  751.                         swipeOutWithSkeleton(direction, function(){
  752.                             loadCardAjax(targetSlug, direction).then(function(data){
  753.                                 if(data){
  754.                                     jobCardContent.innerHTML = buildJobHtml(data);
  755.                                     currentIndex = targetIdx;
  756.                                     jobCard.dataset.currentSlug = targetSlug;
  757.                                     jobCard.dataset.lastJobIndex = String(targetIdx);
  758.                                     updateCounter();
  759.                                     bindLikeHandlers();
  760.                                     prefetchNeighbors();
  761.                                 }
  762.                                 swipeIn(function(){ isAnimating = false; });
  763.                             }).catch(function(err){
  764.                                 console.error('[whr-job] error leaving promo', err);
  765.                                 swipeIn(function(){ isAnimating = false; });
  766.                             });
  767.                         });
  768.                         return;
  769.                     }
  770.                     // Afficher la promo
  771.                     if(goingToPromo){
  772.                         console.log('[whr-job] >>> PROMO');
  773.                         jobCard.dataset.lastJobIndex = String(currentIndex);
  774.                         swipeOutWithSkeleton(direction, function(){
  775.                             jobCardContent.innerHTML = renderPromoCard();
  776.                             currentIndex = -1;
  777.                             if(bannerCounter) bannerCounter.textContent = '★';
  778.                             swipeIn(function(){ isAnimating = false; });
  779.                         });
  780.                         return;
  781.                     }
  782.                     // Navigation normale
  783.                     var nextIdx = (currentIndex + direction + TOTAL) % TOTAL;
  784.                     var nextSlug = SLUGS[nextIdx];
  785.                     swipeOutWithSkeleton(direction, function(){
  786.                         loadCardAjax(nextSlug, direction).then(function(data){
  787.                             if(data){
  788.                                 jobCardContent.innerHTML = buildJobHtml(data);
  789.                                 currentIndex = nextIdx;
  790.                                 jobCard.dataset.currentSlug = nextSlug;
  791.                                 jobCard.dataset.lastJobIndex = String(nextIdx);
  792.                                 updateCounter();
  793.                                 bindLikeHandlers();
  794.                                 prefetchNeighbors();
  795.                             } else {
  796.                                 console.warn('[whr-job] AJAX failed for slug', nextSlug);
  797.                             }
  798.                             swipeIn(function(){ isAnimating = false; });
  799.                         }).catch(function(err){
  800.                             console.error('[whr-job] AJAX error', err);
  801.                             swipeIn(function(){ isAnimating = false; });
  802.                         });
  803.                     });
  804.                 }
  805.                 // Variante de swipeOut qui affiche le skeleton DÈS que la card est sortie
  806.                 function swipeOutWithSkeleton(direction, cb){
  807.                     if(!jobCard) { if(cb) cb(); return; }
  808.                     jobCard.classList.remove('dragging');
  809.                     jobCard.style.transform = '';
  810.                     if(indicatorPrev) indicatorPrev.style.opacity = '0';
  811.                     if(indicatorNext) indicatorNext.style.opacity = '0';
  812.                     void jobCard.offsetWidth;
  813.                     var outClass = direction > 0 ? 'swipe-out-left' : 'swipe-out-right';
  814.                     jobCard.classList.add(outClass);
  815.                     setTimeout(function(){
  816.                         // Afficher le skeleton dès la sortie de la card (avant le AJAX)
  817.                         jobCardContent.innerHTML = renderSkeleton();
  818.                         jobCard.classList.remove('swipe-out-left', 'swipe-out-right');
  819.                         if(cb) cb();
  820.                     }, 320);
  821.                 }
  822.                 function swipeOut(direction, cb){
  823.                     if(!jobCard) { if(cb) cb(); return; }
  824.                     // Nettoyer toute transform inline et la classe dragging
  825.                     jobCard.classList.remove('dragging');
  826.                     jobCard.style.transform = '';
  827.                     // Cacher les indicateurs visuels du drag
  828.                     if(indicatorPrev) indicatorPrev.style.opacity = '0';
  829.                     if(indicatorNext) indicatorNext.style.opacity = '0';
  830.                     // Force un reflow avant d'ajouter la classe pour que la transition redémarre
  831.                     void jobCard.offsetWidth;
  832.                     var outClass = direction > 0 ? 'swipe-out-left' : 'swipe-out-right';
  833.                     jobCard.classList.add(outClass);
  834.                     setTimeout(function(){
  835.                         jobCard.classList.remove('swipe-out-left', 'swipe-out-right');
  836.                         if(cb) cb();
  837.                     }, 320);
  838.                 }
  839.                 function swipeIn(cb){
  840.                     if(!jobCard) { if(cb) cb(); return; }
  841.                     // Nettoyer tout transform inline résiduel
  842.                     jobCard.style.transform = '';
  843.                     jobCard.classList.remove('swipe-in');
  844.                     // Force un reflow puis lance l'animation
  845.                     void jobCard.offsetWidth;
  846.                     jobCard.classList.add('swipe-in');
  847.                     setTimeout(function(){
  848.                         jobCard.classList.remove('swipe-in');
  849.                         if(cb) cb();
  850.                     }, 380);
  851.                 }
  852.                 // ═══ Drag & swipe ═══
  853.                 var SWIPE_THRESHOLD = 100;
  854.                 var startX = 0, startY = 0, currentX = 0, currentY = 0;
  855.                 var isDragging = false, hasMoved = false;
  856.                 function resetCard(){
  857.                     if(!jobCard) return;
  858.                     jobCard.classList.remove('dragging');
  859.                     jobCard.style.transform = '';
  860.                     if(indicatorPrev) indicatorPrev.style.opacity = '0';
  861.                     if(indicatorNext) indicatorNext.style.opacity = '0';
  862.                 }
  863.                 function updateDragPos(deltaX, deltaY){
  864.                     var rotate = deltaX / 30;
  865.                     jobCard.style.transform = 'translate(' + deltaX + 'px,' + deltaY + 'px) rotate(' + rotate + 'deg)';
  866.                     var ratio = Math.min(Math.abs(deltaX) / SWIPE_THRESHOLD, 1);
  867.                     if(deltaX > 0){
  868.                         if(indicatorPrev) indicatorPrev.style.opacity = ratio;
  869.                         if(indicatorNext) indicatorNext.style.opacity = '0';
  870.                     } else if(deltaX < 0){
  871.                         if(indicatorNext) indicatorNext.style.opacity = ratio;
  872.                         if(indicatorPrev) indicatorPrev.style.opacity = '0';
  873.                     } else {
  874.                         if(indicatorPrev) indicatorPrev.style.opacity = '0';
  875.                         if(indicatorNext) indicatorNext.style.opacity = '0';
  876.                     }
  877.                 }
  878.                 function onPointerDown(e){
  879.                     if(isAnimating) return; // pas de drag pendant l'animation
  880.                     var target = e.target;
  881.                     if(target.closest('a, button, input')) return;
  882.                     isDragging = true;
  883.                     hasMoved = false;
  884.                     jobCard.classList.remove('intro-pulse');
  885.                     var pt = e.touches ? e.touches[0] : e;
  886.                     startX = pt.clientX;
  887.                     startY = pt.clientY;
  888.                     jobCard.classList.add('dragging');
  889.                 }
  890.                 function onPointerMove(e){
  891.                     if(!isDragging) return;
  892.                     var pt = e.touches ? e.touches[0] : e;
  893.                     currentX = pt.clientX - startX;
  894.                     currentY = pt.clientY - startY;
  895.                     if(Math.abs(currentX) > 6) hasMoved = true;
  896.                     if(Math.abs(currentY) > Math.abs(currentX) * 1.5 && !hasMoved) return;
  897.                     if(e.cancelable && hasMoved && Math.abs(currentX) > 12) e.preventDefault();
  898.                     updateDragPos(currentX, currentY * 0.3);
  899.                 }
  900.                 function onPointerUp(){
  901.                     if(!isDragging) return;
  902.                     isDragging = false;
  903.                     if(Math.abs(currentX) > SWIPE_THRESHOLD){
  904.                         if(currentX > 0){
  905.                             // Swipe droite → précédent (AJAX)
  906.                             navigateAjax(-1);
  907.                         } else {
  908.                             // Swipe gauche → suivant (AJAX)
  909.                             navigateAjax(1);
  910.                         }
  911.                     } else {
  912.                         resetCard();
  913.                     }
  914.                     currentX = 0; currentY = 0;
  915.                     if(indicatorPrev) indicatorPrev.style.opacity = '0';
  916.                     if(indicatorNext) indicatorNext.style.opacity = '0';
  917.                 }
  918.                 if(jobCard){
  919.                     jobCard.addEventListener('touchstart', onPointerDown, {passive:true});
  920.                     jobCard.addEventListener('touchmove', onPointerMove, {passive:false});
  921.                     jobCard.addEventListener('touchend', onPointerUp);
  922.                     jobCard.addEventListener('touchcancel', onPointerUp);
  923.                     jobCard.addEventListener('mousedown', onPointerDown);
  924.                     document.addEventListener('mousemove', onPointerMove);
  925.                     document.addEventListener('mouseup', onPointerUp);
  926.                 }
  927.                 // ═══ Raccourcis clavier ═══
  928.                 document.addEventListener('keydown', function(e){
  929.                     var tag = (e.target && e.target.tagName) ? e.target.tagName.toUpperCase() : '';
  930.                     if(tag === 'INPUT' || tag === 'TEXTAREA') return;
  931.                     if(e.key === 'ArrowLeft' && TOTAL > 1){ e.preventDefault(); navigateAjax(-1); }
  932.                     else if(e.key === 'ArrowRight' && TOTAL > 1){ e.preventDefault(); navigateAjax(1); }
  933.                 });
  934.                 // ═══ Table of contents — scroll spy ═══
  935.                 var tocLinks = document.querySelectorAll('.side-toc-link');
  936.                 if(tocLinks.length){
  937.                     tocLinks.forEach(function(link){
  938.                         link.addEventListener('click', function(e){
  939.                             e.preventDefault();
  940.                             var targetId = link.getAttribute('data-target');
  941.                             var target = document.getElementById(targetId);
  942.                             if(target) target.scrollIntoView({behavior:'smooth', block:'start'});
  943.                         });
  944.                     });
  945.                     var sections = [];
  946.                     tocLinks.forEach(function(link){
  947.                         var id = link.getAttribute('data-target');
  948.                         var el = document.getElementById(id);
  949.                         if(el) sections.push({id:id, el:el, link:link});
  950.                     });
  951.                     function updateActiveTocLink(){
  952.                         var scrollPos = window.scrollY + 100;
  953.                         var current = sections[0];
  954.                         sections.forEach(function(s){ if(s.el.offsetTop <= scrollPos) current = s; });
  955.                         tocLinks.forEach(function(l){ l.classList.remove('active'); });
  956.                         if(current) current.link.classList.add('active');
  957.                     }
  958.                     window.addEventListener('scroll', updateActiveTocLink, {passive:true});
  959.                     updateActiveTocLink();
  960.                 }
  961.                 // Init counter
  962.                 updateCounter();
  963.                 prefetchNeighbors();
  964.                 // Stop intro-pulse au premier interaction utilisateur
  965.                 ['mousedown','touchstart','keydown'].forEach(function(evt){
  966.                     document.addEventListener(evt, function(){
  967.                         if(jobCard) jobCard.classList.remove('intro-pulse');
  968.                     }, {once:true});
  969.                 });
  970.                 console.log('[whr-job] init done', {TOTAL: TOTAL, SLUGS: SLUGS});
  971.             } catch(err){
  972.                 console.error('[whr-job] init error:', err);
  973.             }
  974.         })();
  975.     </script>
  976. {% endblock footerjs %}