{% extends 'application/whileresume/application/jobs/layout-social.html.twig' %}{% trans_default_domain 'whr-public' %}{% block title %}{{ job.jobTitle }} — {{ job.companyName }}{% endblock title %}{% block description %}{{ job.jobSummary|striptags|slice(0, 160) }}{% endblock description %}{% block robots %}index,follow{% endblock robots %}{% block css %} <style> /* ═══════════════════════════════════════════════════════════════ PAGE OFFRE — Pile de cards swipeable + AJAX ═══════════════════════════════════════════════════════════════ */ /* ─── Layout 2 colonnes ─── */ .job-layout{display:block;max-width:880px;margin:0 auto} .job-main{width:100%} /* ─── Bandeau d'info "swipez pour naviguer" ─── */ .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} .swipe-banner-info{display:flex;align-items:center;gap:8px;flex:1;min-width:0} .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} .swipe-banner-icon svg{width:13px;height:13px} @keyframes bannerPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}} .swipe-banner-text{color:#5B21B6;font-weight:500;line-height:1.4} .swipe-banner-text strong{color:var(--theme-color,#6C3AED);font-weight:700} .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} /* ─── Boutons navigation prev/next ─── */ .job-nav{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;gap:12px} .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} .job-nav-btn:hover{transform:translateY(-1px)} .job-nav-btn-prev{background:#fff;border-color:#E5E7EB;color:#1E1B2E} .job-nav-btn-prev:hover{border-color:var(--theme-color,#6C3AED);color:var(--theme-color,#6C3AED)} .job-nav-btn-next{background:#1E1B2E;color:#fff} .job-nav-btn-next:hover{filter:brightness(1.5);color:#fff} .job-nav-btn svg{width:14px;height:14px;flex-shrink:0} @media(max-width:480px){ .job-nav-btn span{display:none} .job-nav-btn{padding:10px 12px} } /* ═══════════════════════════════════════════════════════════════ PILE DE CARDS — Card centrale + cards fantômes ═══════════════════════════════════════════════════════════════ */ .stack-wrap{position:relative;perspective:1000px} /* Cards fantômes derrière (effet pile) */ .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} .stack-ghost-1{top:8px;left:3%;right:3%;bottom:0;opacity:.6;transform:scale(.97)} .stack-ghost-2{top:16px;left:6%;right:6%;bottom:0;opacity:.3;transform:scale(.94)} @media(max-width:480px){.stack-ghost{display:none}} /* Card centrale */ .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} .job-card-main:active{cursor:grabbing} .job-card-main.dragging{transition:none} .job-card-main.swipe-out-right{transform:translateX(120%) rotate(8deg);opacity:0} .job-card-main.swipe-out-left{transform:translateX(-120%) rotate(-8deg);opacity:0} .job-card-main.swipe-in{animation:swipeIn .35s ease forwards} @keyframes swipeIn{0%{transform:translateX(0) scale(.95);opacity:.5}100%{transform:translateX(0) scale(1);opacity:1}} /* ─── Skeleton loader (pendant chargement AJAX) ─── */ .skeleton{padding:24px} @media(max-width:480px){.skeleton{padding:18px}} .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} @keyframes skeletonShimmer{0%{background-position:200% 0}100%{background-position:-200% 0}} .skeleton-pills{display:flex;gap:6px;margin-bottom:16px} .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} .skeleton-pill.s-w90{width:90px} .skeleton-title{height:32px;width:75%;margin-bottom:8px;border-radius:8px} .skeleton-title-2{height:32px;width:50%;margin-bottom:18px;border-radius:8px} .skeleton-meta{display:flex;gap:6px;margin-bottom:24px;flex-wrap:wrap} .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} .skeleton-section{padding:20px 24px;border-top:1px solid #F3F4F6} @media(max-width:480px){.skeleton-section{padding:16px 18px}} .skeleton-section-head{display:flex;align-items:center;gap:10px;margin-bottom:14px} .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} .skeleton-section-title{height:18px;width:140px;border-radius:6px} .skeleton-line-text{height:13px;margin-bottom:8px} .skeleton-w-full{width:100%} .skeleton-w-90{width:90%} .skeleton-w-75{width:75%} .skeleton-w-60{width:60%} /* Pulse au chargement initial (1 fois) */ .job-card-main.intro-pulse{animation:introPulse 1.5s ease-out forwards} @keyframes introPulse{ 0%{transform:translateX(0)} 20%{transform:translateX(20px) rotate(2deg)} 40%{transform:translateX(-15px) rotate(-1.5deg)} 60%{transform:translateX(8px) rotate(.8deg)} 80%{transform:translateX(-3px) rotate(-.3deg)} 100%{transform:translateX(0)} } /* Indicateurs swipe (apparaissent pendant le drag) */ .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)} .swipe-indicator-prev{left:20px;color:#16A34A;border-color:#16A34A;background:rgba(220,252,231,.95)} .swipe-indicator-next{right:20px;color:#6C3AED;border-color:#6C3AED;background:rgba(245,243,255,.95);transform:translateY(-50%) rotate(15deg)} /* Header card minimaliste : titre direct */ .job-card-head{padding:24px 24px 16px} @media(max-width:480px){.job-card-head{padding:18px 18px 14px}} .job-card-title{font-size:24px;font-weight:800;color:#1E1B2E;line-height:1.2;letter-spacing:-0.02em;margin:0 0 12px} @media(min-width:768px){.job-card-title{font-size:28px}} @media(max-width:480px){.job-card-title{font-size:22px}} .job-card-meta{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:0} .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} .job-card-meta-item svg{width:12px;height:12px;flex-shrink:0;color:#6B7280} .job-card-meta-salary{background:#F5F3FF;color:var(--theme-color,#6C3AED);font-weight:600} .job-card-meta-salary svg{color:currentColor} .job-card-meta-active{background:#ECFDF5;color:#059669;font-weight:600} .job-card-meta-external{background:#FFFBEB;color:#92400E;font-weight:600} .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} /* Sections */ .job-card-section{padding:20px 24px;border-top:1px solid #F3F4F6} @media(max-width:480px){.job-card-section{padding:16px 18px}} .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} .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)} .job-card-section-icon svg{width:16px;height:16px} .job-card-section-text{font-size:14px;line-height:1.7;color:#374151;margin:0} /* Footer card */ .job-card-actions{display:flex;align-items:center;gap:8px;padding:16px 24px;border-top:1px solid #F3F4F6;background:#FAFAFA} @media(max-width:480px){.job-card-actions{padding:12px 16px;flex-wrap:wrap}} .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} .job-action-btn:hover{border-color:var(--theme-color,#6C3AED);color:var(--theme-color,#6C3AED);transform:translateY(-1px)} .job-action-btn svg{width:14px;height:14px} .job-action-btn-primary{background:var(--theme-color,#6C3AED);color:#fff;border-color:var(--theme-color,#6C3AED);flex:1;justify-content:center} .job-action-btn-primary:hover{background:#5B21B6;color:#fff;border-color:#5B21B6} .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} .job-action-btn-locked:hover{background:#F5F3FF !important;border-color:var(--theme-color,#6C3AED) !important;color:var(--theme-color,#6C3AED) !important} /* ─── Bouton like (état + animation) ─── */ .job-action-like{position:relative;overflow:hidden} .job-action-like .job-action-like-icon{transition:transform .25s ease,fill .2s ease} .job-action-like.is-liked{background:#16A34A !important;border-color:#16A34A !important;color:#fff !important} .job-action-like.is-liked:hover{background:#15803D !important;border-color:#15803D !important;color:#fff !important} .job-action-like.is-liked .job-action-like-icon{fill:#fff;transform:scale(1.05)} .job-action-like.is-loading{opacity:.65;pointer-events:none} .job-action-like.is-loading .job-action-like-icon{animation:likeSpin .8s linear infinite} @keyframes likeSpin{from{transform:rotate(0)}to{transform:rotate(360deg)}} .job-action-like.like-pulse{animation:likePulse .42s cubic-bezier(.34,1.56,.64,1)} @keyframes likePulse{0%{transform:scale(1)}45%{transform:scale(1.08)}100%{transform:scale(1)}} /* Card lock (non-connectés) */ .job-card-locked{padding:24px;background:linear-gradient(135deg,#F5F3FF,#FAFAFA);border-top:1px solid #F3F4F6;text-align:center} .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)} .job-card-locked-title{font-size:16px;font-weight:700;color:#1E1B2E;margin:0 0 4px} .job-card-locked-text{font-size:13px;color:#6B7280;margin:0 0 14px;line-height:1.5} .job-card-locked-buttons{display:flex;gap:8px;justify-content:center;flex-wrap:wrap} .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} .job-card-locked-btn-primary{background:var(--theme-color,#6C3AED);color:#fff} .job-card-locked-btn-primary:hover{background:#5B21B6;color:#fff;transform:translateY(-1px)} .job-card-locked-btn-secondary{background:#fff;color:#1E1B2E;border:1.5px solid #E5E7EB} .job-card-locked-btn-secondary:hover{border-color:var(--theme-color,#6C3AED);color:var(--theme-color,#6C3AED)} /* ─── Card promo (blanche, logo centré, bullet list, boutons empilés) ─── */ .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)} @media(max-width:480px){.promo-card{padding:24px 20px}} .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)} .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} .promo-card-title{font-size:22px;font-weight:800;color:#1E1B2E;margin:0 0 10px;line-height:1.2;letter-spacing:-0.01em} @media(min-width:768px){.promo-card-title{font-size:26px}} .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} .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} .promo-card-bullet{display:flex;align-items:flex-start;gap:10px;font-size:14px;color:#374151;line-height:1.5} .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} .promo-card-bullet-icon svg{width:12px;height:12px;color:var(--theme-color,#6C3AED)} .promo-card-bullet-text{flex:1} .promo-card-bullet-text strong{color:#1E1B2E;font-weight:700} .promo-card-buttons{display:flex;flex-direction:column;gap:10px;max-width:340px;margin-left:auto;margin-right:auto} .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} .promo-card-btn:hover{transform:translateY(-1px)} .promo-card-btn svg{width:16px;height:16px;flex-shrink:0} .promo-card-btn-pastel{background:#F5F3FF;color:var(--theme-color,#6C3AED)} .promo-card-btn-pastel:hover{background:#EDE9FE;color:var(--theme-color,#6C3AED)} .promo-card-btn-outline{background:#fff;color:var(--theme-color,#6C3AED);border:1.5px solid #EDE9FE} .promo-card-btn-outline:hover{background:#FAFAFA;border-color:#DDD6FE;color:var(--theme-color,#6C3AED)} /* Hint clavier sous la pile */ .job-keyboard-hint{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:14px;font-size:11px;color:#9CA3AF} .job-keyboard-hint kbd{padding:2px 6px;background:#F3F4F6;border-radius:4px;font-size:10px;font-family:monospace} .job-keyboard-hint-sep{color:#D1D5DB} @media(max-width:640px){.job-keyboard-hint{display:none}} /* ─── Section "Toutes les offres similaires" en feed (en bas) ─── */ .similar-section-title{font-size:11px;font-weight:700;color:#9CA3AF;text-transform:uppercase;letter-spacing:.08em;margin:32px 0 12px;padding-left:4px} .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} .similar-card:hover{transform:translateY(-1px);box-shadow:0 4px 20px rgba(108,58,237,.1);color:inherit} .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} .similar-card-logo img{width:100%;height:100%;object-fit:cover;border-radius:8px} .similar-card-logo svg{width:24px;height:24px;opacity:.85} .similar-card-info{flex:1;min-width:0} .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} .similar-card-meta{font-size:12px;color:#6B7280;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .similar-card-arrow{flex-shrink:0;color:#9CA3AF;transition:color .15s} .similar-card:hover .similar-card-arrow{color:var(--theme-color,#6C3AED)} /* Auth modal */ .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} .auth-modal-backdrop.visible{opacity:1;pointer-events:auto} .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} .auth-modal.visible{opacity:1;pointer-events:auto;transform:translate(-50%,-50%) scale(1)} .auth-modal-header{padding:24px 24px 8px;text-align:center;position:relative} .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} .auth-modal-close:hover{background:#E5E7EB} .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)} .auth-modal-title{font-size:22px;font-weight:800;color:#1E1B2E;margin:0 0 8px} .auth-modal-subtitle{font-size:14px;color:#6B7280;margin:0;line-height:1.5} .auth-modal-body{padding:8px 24px 24px} .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} .auth-option:hover{border-color:rgba(108,58,237,.3);background:#FAFAFA;transform:translateY(-1px);color:inherit} .auth-option-icon{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;flex-shrink:0} .auth-option-icon.ios{background:#000;color:#fff} .auth-option-icon.android{background:#34A853;color:#fff} .auth-option-icon.web{background:#F5F3FF;color:var(--theme-color,#6C3AED)} .auth-option-text{flex:1;min-width:0} .auth-option-title{font-size:15px;font-weight:700;color:#1E1B2E;margin:0 0 2px} .auth-option-desc{font-size:13px;color:#6B7280;margin:0} .auth-option-arrow{color:#9CA3AF;flex-shrink:0} .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} .auth-divider::before,.auth-divider::after{content:'';flex:1;height:1px;background:#E5E7EB} </style>{% endblock css %}{% block body %} {# ═══ Layout 1 colonne centrée (les filtres sont dans la sidebar Sociala gauche) ═══ #} <div class="job-layout"> <main class="job-main"> {# Variables pile #} {% set hasNeighbors = similarJobs is defined and similarJobs is not empty %} {% if hasNeighbors %} {% set prevSlug = (similarJobs|last).slug %} {% set nextSlug = (similarJobs|first).slug %} {% set totalOffers = similarJobs|length + 1 %} {% endif %} {# Bandeau d'info "Vous pouvez swiper" #} {% if hasNeighbors %} <div class="swipe-banner"> <div class="swipe-banner-info"> <span class="swipe-banner-icon"> <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> </span> <span class="swipe-banner-text"> <strong>{{ similarJobs|length }}</strong> {{ 'job.show.bannerSimilar'|trans }} </span> </div> <span class="swipe-banner-counter" id="bannerCounter">1 / {{ totalOffers }}</span> </div> {% endif %} {# Boutons navigation prev/next (URL change) #} {% if hasNeighbors %} <div class="job-nav"> <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 %}" class="job-nav-btn job-nav-btn-prev" id="jobNavPrev" data-slug="{{ prevSlug }}"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg> <span>{{ 'job.show.previousOffer'|trans }}</span> </a> <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 %}" class="job-nav-btn job-nav-btn-next" id="jobNavNext" data-slug="{{ nextSlug }}"> <span>{{ 'job.show.nextOffer'|trans }}</span> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg> </a> </div> {% endif %} {# ═══ PILE de cards ═══ #} <div class="stack-wrap" id="stackWrap"> {# Cards fantômes derrière #} {% if hasNeighbors %} <div class="stack-ghost stack-ghost-2"></div> <div class="stack-ghost stack-ghost-1"></div> {% endif %} {# Card centrale (contenu remplacé en AJAX au swipe) #} <div class="job-card-main {% if hasNeighbors %}intro-pulse{% endif %}" id="jobCardMain" data-current-slug="{{ job.slug }}"> <div class="swipe-indicator swipe-indicator-prev" id="swipeIndicatorPrev">{{ 'job.show.previousOffer'|trans }}</div> <div class="swipe-indicator swipe-indicator-next" id="swipeIndicatorNext">{{ 'job.show.nextOffer'|trans }}</div> <div id="jobCardContent"> {% include "application/whileresume/application/jobs/_card-content.html.twig" %} </div> </div> </div> {# Hint clavier + swipe #} {% if hasNeighbors %} <div class="job-keyboard-hint"> {{ 'job.show.swipeHintFull'|trans|raw }} </div> {% endif %} {# ═══ Liste des offres similaires en bas ═══ #} {% if hasNeighbors %} <h4 class="similar-section-title">{{ 'job.show.similarOffers'|trans }} · {{ similarJobs|length }}</h4> {% for sj in similarJobs %} <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 %}" class="similar-card"> <div class="similar-card-logo"> {% if sj.enterprise and sj.enterprise.image and sj.enterprise.image.name %} <img src="{{ vich_uploader_asset(sj.enterprise, 'imageFile') }}" alt=""> {% else %} <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> {% endif %} </div> <div class="similar-card-info"> <div class="similar-card-title">{{ sj.jobTitle }}</div> <div class="similar-card-meta"> {{ sj.companyName }}{% if sj.city is not empty %} · {{ sj.city }}{% endif %}{% if sj.employmentType is not empty %} · {{ sj.employmentType }}{% endif %} </div> </div> <span class="similar-card-arrow"> <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> </span> </a> {% endfor %} {% endif %} </main> </div> {# Auth modal #} {% if not isCandidateUser %} <div class="auth-modal-backdrop" id="authModalBackdrop"></div> <div class="auth-modal" id="authModal" role="dialog" aria-modal="true"> <div class="auth-modal-header"> <button class="auth-modal-close" id="authModalClose" aria-label="Close"> <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> </button> <div class="auth-modal-icon"> <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> </div> <h3 class="auth-modal-title">{{ 'job.show.authModalTitle'|trans }}</h3> <p class="auth-modal-subtitle">{{ 'job.show.authModalSubtitle'|trans }}</p> </div> <div class="auth-modal-body"> <a href="{{ ios }}" target="_blank" rel="noopener" class="auth-option"> <div class="auth-option-icon ios"> <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> </div> <div class="auth-option-text"> <p class="auth-option-title">{{ 'job.show.authIosTitle'|trans }}</p> <p class="auth-option-desc">{{ 'job.show.authIosDesc'|trans }}</p> </div> <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> </a> <a href="{{ android }}" target="_blank" rel="noopener" class="auth-option"> <div class="auth-option-icon android"> <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> </div> <div class="auth-option-text"> <p class="auth-option-title">{{ 'job.show.authAndroidTitle'|trans }}</p> <p class="auth-option-desc">{{ 'job.show.authAndroidDesc'|trans }}</p> </div> <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> </a> <div class="auth-divider">{{ 'job.show.orDivider'|trans }}</div> <a href="{% if app.request.locale == 'en' %}{{ path('app_login') }}{% else %}{{ path('locale_app_login',{'_locale':app.request.locale}) }}{% endif %}" class="auth-option"> <div class="auth-option-icon web"> <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> </div> <div class="auth-option-text"> <p class="auth-option-title">{{ 'job.show.authWebTitle'|trans }}</p> <p class="auth-option-desc">{{ 'job.show.authWebDesc'|trans }}</p> </div> <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> </a> </div> </div> {% endif %}{% endblock body %}{% block footerjs %} {# Re-déclaration variables (pas accessible cross-block) #} {% set hasNeighbors = similarJobs is defined and similarJobs is not empty %} <script> (function(){ 'use strict'; try { console.log('[whr-job] init script'); var IS_AUTHENTICATED = {{ app.user is not null ? 'true' : 'false' }}; var IS_CANDIDATE = {{ (app.user is not null and app.user.candidate is not null) ? 'true' : 'false' }}; var LOCALE = '{{ app.request.locale }}'; var IOS_URL = '{{ ios|default('')|escape('js') }}'; var ANDROID_URL = '{{ android|default('')|escape('js') }}'; // Fallbacks vers la home si les liens stores ne sont pas configurés if(!IOS_URL) IOS_URL = '{{ ios }}'; if(!ANDROID_URL) ANDROID_URL = '{{ android }}'; {% if app.request.locale == "fr" %} var SEARCH_API_URL = '{{ path("locale_cvs_application_jobs_api_search",{"_locale":app.request.locale}) }}'; var AJAX_SHOW_URL_TEMPLATE = '{{ path("locale_cvs_application_job_ajax",{"_locale":app.request.locale,"slug":"__SLUG__"}) }}'; var REGISTER_URL = '{{ path("whileresume_resume_fr") }}'; var LOGIN_URL = '{{ path("locale_app_login",{"_locale":app.request.locale}) }}'; {% else %} var SEARCH_API_URL = '{{ path("cvs_application_jobs_api_search",{"_locale":app.request.locale}) }}'; var AJAX_SHOW_URL_TEMPLATE = '{{ path("cvs_application_job_ajax",{"_locale":app.request.locale,"slug":"__SLUG__"}) }}'; var REGISTER_URL = '{{ path("whileresume_resume_en") }}'; var LOGIN_URL = '{{ path("app_login") }}'; {% endif %} // Liste cyclique des slugs (offre courante + similaires) var SLUGS = [ '{{ job.slug }}' {% if hasNeighbors %}{% for sj in similarJobs %}, '{{ sj.slug }}'{% endfor %}{% endif %} ]; var TOTAL = SLUGS.length; var currentIndex = 0; var swipeCount = 0; // nombre de cards (vraies offres) chargées via swipe var PROMO_EVERY_N_SWIPES = 3; // afficher la promo toutes les N navigations (cyclique) // ═══ Auth modal ═══ var authModal = document.getElementById('authModal'); var authBackdrop = document.getElementById('authModalBackdrop'); var authClose = document.getElementById('authModalClose'); function openAuthModal(){ if(!authModal) return; authModal.classList.add('visible'); authBackdrop.classList.add('visible'); document.body.style.overflow = 'hidden'; } function closeAuthModal(){ if(!authModal) return; authModal.classList.remove('visible'); authBackdrop.classList.remove('visible'); document.body.style.overflow = ''; } if(authClose) authClose.addEventListener('click', closeAuthModal); if(authBackdrop) authBackdrop.addEventListener('click', closeAuthModal); // Labels du bouton like (utilisés par applyLikeState et buildJobHtml) var LIKE_LABEL_DEFAULT = {{ 'job.show.imInterested'|trans|json_encode|raw }}; var LIKE_LABEL_LIKED = {{ 'job.show.interested'|trans|json_encode|raw }}; function applyLikeState(btn, liked){ if(!btn) return; btn.dataset.liked = liked ? '1' : '0'; btn.classList.toggle('is-liked', liked); var label = btn.querySelector('.job-action-like-label'); if(label){ label.textContent = liked ? LIKE_LABEL_LIKED : LIKE_LABEL_DEFAULT; } // Animation pulse au like (pas à l'unlike) if(liked){ btn.classList.remove('like-pulse'); // Force reflow pour relancer l'animation void btn.offsetWidth; btn.classList.add('like-pulse'); } } function bindLikeHandlers(){ var apply = document.getElementById('sideCtaApply'); var save = document.getElementById('sideCtaSave'); var cardLike = document.getElementById('cardLikeBtn'); // Boutons sidebar (sideCtaApply, sideCtaSave) : modal d'auth si non-candidat [apply, save].forEach(function(el){ if(!el) return; if(el.dataset.likeBound) return; el.dataset.likeBound = '1'; el.addEventListener('click', function(e){ if(!IS_CANDIDATE){ e.preventDefault(); openAuthModal(); } }); }); // Bouton card : toggle AJAX (like / unlike) if(cardLike && !cardLike.dataset.likeBound){ cardLike.dataset.likeBound = '1'; cardLike.addEventListener('click', function(e){ // Bouton "locked" → modal d'auth if(cardLike.dataset.locked === '1' || !IS_CANDIDATE){ e.preventDefault(); openAuthModal(); return; } // Pas de navigation : on gère en AJAX e.preventDefault(); // Anti double-clic if(cardLike.dataset.likeBusy === '1') return; cardLike.dataset.likeBusy = '1'; cardLike.classList.add('is-loading'); var url = cardLike.getAttribute('href'); fetch(url, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, credentials: 'same-origin' }) .then(function(r){ if(r.status === 401){ openAuthModal(); return null; } if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function(data){ if(!data || !data.success) return; applyLikeState(cardLike, !!data.liked); // Synchronise le cache des voisins prefetchées : // sans ça, un swipe vers cette card ré-utiliserait // l'ancien isLiked figé lors du prefetch initial. var slugChanged = cardLike.dataset.jobSlug || jobCard.dataset.currentSlug; if(slugChanged && cardCache[slugChanged]){ cardCache[slugChanged].isLiked = !!data.liked; } }) .catch(function(err){ console.error('Like AJAX error', err); }) .then(function(){ cardLike.dataset.likeBusy = '0'; cardLike.classList.remove('is-loading'); }); }); } } bindLikeHandlers(); // ═══ Pile : navigation par AJAX (swipe) vs URL (boutons) ═══ var jobCard = document.getElementById('jobCardMain'); var jobCardContent = document.getElementById('jobCardContent'); var indicatorPrev = document.getElementById('swipeIndicatorPrev'); var indicatorNext = document.getElementById('swipeIndicatorNext'); var bannerCounter = document.getElementById('bannerCounter'); function getSlugAt(offset){ var idx = (currentIndex + offset + TOTAL) % TOTAL; return SLUGS[idx]; } function updateCounter(){ if(bannerCounter) bannerCounter.textContent = (currentIndex + 1) + ' / ' + TOTAL; } function renderSkeleton(){ return '' + '<div class="skeleton">' + '<div class="skeleton-pills">' + '<div class="skeleton-pill"></div>' + '<div class="skeleton-pill s-w90"></div>' + '</div>' + '<div class="skeleton-line skeleton-title"></div>' + '<div class="skeleton-line skeleton-title-2"></div>' + '<div class="skeleton-meta">' + '<div class="skeleton-meta-item"></div>' + '<div class="skeleton-meta-item"></div>' + '<div class="skeleton-meta-item" style="width:100px;"></div>' + '</div>' + '</div>' + '<div class="skeleton-section">' + '<div class="skeleton-section-head">' + '<div class="skeleton-section-icon"></div>' + '<div class="skeleton-line skeleton-section-title"></div>' + '</div>' + '<div class="skeleton-line skeleton-line-text skeleton-w-full"></div>' + '<div class="skeleton-line skeleton-line-text skeleton-w-90"></div>' + '<div class="skeleton-line skeleton-line-text skeleton-w-full"></div>' + '<div class="skeleton-line skeleton-line-text skeleton-w-75"></div>' + '<div class="skeleton-line skeleton-line-text skeleton-w-60"></div>' + '</div>' + '<div class="skeleton-section">' + '<div class="skeleton-section-head">' + '<div class="skeleton-section-icon"></div>' + '<div class="skeleton-line skeleton-section-title"></div>' + '</div>' + '<div class="skeleton-line skeleton-line-text skeleton-w-full"></div>' + '<div class="skeleton-line skeleton-line-text skeleton-w-90"></div>' + '<div class="skeleton-line skeleton-line-text skeleton-w-75"></div>' + '</div>'; } function renderPromoCard(){ 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>'; return '' + '<div class="promo-card">' + '<div class="promo-card-logo">' + '<img src="/uploads/favicon.png" alt="Whileresume">' + '</div>' + '<h2 class="promo-card-title">{{ 'job.show.promoTitle'|trans|escape('js') }}</h2>' + '<p class="promo-card-text">{{ 'job.show.promoText'|trans|escape('js') }}</p>' + '<ul class="promo-card-bullets">' + '<li class="promo-card-bullet">' + '<span class="promo-card-bullet-icon">' + checkSvg + '</span>' + '<span class="promo-card-bullet-text">{{ 'job.show.promoBullet1'|trans|escape('js') }}</span>' + '</li>' + '<li class="promo-card-bullet">' + '<span class="promo-card-bullet-icon">' + checkSvg + '</span>' + '<span class="promo-card-bullet-text">{{ 'job.show.promoBullet2'|trans|escape('js') }}</span>' + '</li>' + '<li class="promo-card-bullet">' + '<span class="promo-card-bullet-icon">' + checkSvg + '</span>' + '<span class="promo-card-bullet-text">{{ 'job.show.promoBullet3'|trans|escape('js') }}</span>' + '</li>' + '</ul>' + '<div class="promo-card-buttons">' + '<a href="' + REGISTER_URL + '" class="promo-card-btn promo-card-btn-pastel">' + '<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>' + '{{ 'job.show.promoBtnSignup'|trans|escape('js') }}' + '</a>' + '<a href="' + LOGIN_URL + '" class="promo-card-btn promo-card-btn-outline">' + '<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>' + '{{ 'job.show.promoBtnSignin'|trans|escape('js') }}' + '</a>' + '<a href="' + IOS_URL + '" target="_blank" rel="noopener" class="promo-card-btn promo-card-btn-pastel">' + '<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>' + '{{ 'job.show.promoBtnIos'|trans|escape('js') }}' + '</a>' + '<a href="' + ANDROID_URL + '" target="_blank" rel="noopener" class="promo-card-btn promo-card-btn-pastel">' + '<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>' + '{{ 'job.show.promoBtnAndroid'|trans|escape('js') }}' + '</a>' + '</div>' + '</div>'; } function buildJobHtml(data){ if(!data) return ''; var meta = ''; // Pilule "Offre active" en premier dans les meta 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>'; if(data.verification === 0 || data.verification === false){ meta += '<span class="job-card-meta-item job-card-meta-external">⚡ {{ 'job.show.externalSource'|trans|escape('js') }}</span>'; } if(data.city){ 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>'; } if(data.employmentType){ 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>'; } if(data.remoteWork){ 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>'; } if(data.salaryPeriod){ 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>'; } var html = '<div class="job-card-head">' + '<h1 class="job-card-title">' + escapeHtml(data.title) + '</h1>' + '<div class="job-card-meta">' + meta + '</div>' + '</div>'; if(data.jobSummary){ html += '<div class="job-card-section" id="section-presentation">' + '<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>' + '<p class="job-card-section-text">' + nl2brEscape(data.jobSummary) + '</p>' + '</div>'; } if(data.locked){ html += '<div class="job-card-locked">' + '<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>' + '<div class="job-card-locked-title">{{ 'job.show.unlockTitle'|trans|escape('js') }}</div>' + '<div class="job-card-locked-text">{{ 'job.show.unlockText'|trans|escape('js') }}</div>' + '<div class="job-card-locked-buttons">' + '<a href="' + REGISTER_URL + '" class="job-card-locked-btn job-card-locked-btn-primary">{{ 'job.show.createProfileFree'|trans|escape('js') }}</a>' + '</div>' + '</div>'; } else { if(data.keyResponsabilities){ 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>'; } if(data.requirements){ 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>'; } if(data.benefits){ 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>'; } } // ═══ BOUTONS D'ACTION ═══ // Logique : // - Offre indexée (websearch=1) + website : 'Postuler en externe' = bouton PRINCIPAL violet // 'Sauvegarder' = bouton SECONDAIRE (heart) // - Offre interne : 'Je suis intéressé' reste le bouton principal (heart) // Le bouton 'Sauvegarder' demande la connexion si user anonyme. var isIndexed = (data.websearch == 1 && data.website); var actionsHtml = '<div class="job-card-actions">'; if(isIndexed){ // 1. Bouton PRINCIPAL : Postuler en externe (toujours dispo, même anonyme) actionsHtml += '<a href="' + escapeHtml(data.website) + '" target="_blank" rel="noopener" class="job-action-btn job-action-btn-primary">' + '<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>' + '{{ 'job.show.applyNow'|trans|escape('js') }}' + '</a>'; // 2. Bouton SECONDAIRE : Sauvegarder (heart, en favori) if(data.likeUrl){ actionsHtml += '<a href="' + data.likeUrl + '" class="job-action-btn">' + '<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>' + '{{ 'job.show.save'|trans|escape('js') }}' + '</a>'; } else { actionsHtml += '<button class="job-action-btn job-action-btn-locked" id="cardLikeBtn" type="button" data-locked="1">' + '<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>' + '{{ 'job.show.save'|trans|escape('js') }}' + '</button>'; } } else { // Offre interne : 'Je suis intéressé' reste le bouton principal if(data.likeUrl){ var liked = !!data.isLiked; var likeLabel = liked ? LIKE_LABEL_LIKED : LIKE_LABEL_DEFAULT; actionsHtml += '<a href="' + data.likeUrl + '" ' + 'class="job-action-btn job-action-btn-primary job-action-like' + (liked ? ' is-liked' : '') + '" ' + 'id="cardLikeBtn" ' + 'data-liked="' + (liked ? '1' : '0') + '" ' + 'data-job-slug="' + escapeHtml(data.slug) + '">' + '<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>' + '<span class="job-action-like-label">' + escapeHtml(likeLabel) + '</span>' + '</a>'; } else { actionsHtml += '<button class="job-action-btn job-action-btn-primary job-action-btn-locked" id="cardLikeBtn" type="button" data-locked="1">' + '<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>' + '{{ 'job.show.imInterestedLocked'|trans|escape('js') }}' + '</button>'; } } actionsHtml += '</div>'; html += actionsHtml; return html; } function escapeHtml(s){ if(s == null) return ''; return String(s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); } function nl2brEscape(s){ if(s == null) return ''; // Décoder les \n littéraux puis échapper et remplacer en <br> var t = String(s).replace(/\\n/g, '\n'); return escapeHtml(t).replace(/\n/g, '<br>'); } // ═══ Cache des cards déjà chargées ═══ var cardCache = {}; // On préload la card courante depuis le DOM (vu qu'elle est déjà rendue) // Pas nécessaire — on ne la rechargera jamais via AJAX vu qu'on est dessus. function loadCardAjax(slug, direction){ if(!slug) return Promise.resolve(null); if(cardCache[slug]) return Promise.resolve(cardCache[slug]); var url = AJAX_SHOW_URL_TEMPLATE.replace('__SLUG__', encodeURIComponent(slug)); return fetch(url, {headers:{'Accept':'application/json'}}) .then(function(r){ return r.ok ? r.json() : null; }) .then(function(data){ if(data) cardCache[slug] = data; return data; }) .catch(function(){ return null; }); } // Précharger les voisins de la card courante en background function prefetchNeighbors(){ if(TOTAL < 2) return; var prevIdx = (currentIndex - 1 + TOTAL) % TOTAL; var nextIdx = (currentIndex + 1) % TOTAL; if(currentIndex >= 0){ loadCardAjax(SLUGS[prevIdx]); loadCardAjax(SLUGS[nextIdx]); } } // Lock pour éviter les swipes en parallèle pendant l'animation var isAnimating = false; function navigateAjax(direction){ if(isAnimating) return; isAnimating = true; jobCard.classList.remove('intro-pulse'); var leavingPromo = (currentIndex === -1); // Toujours incrémenter à chaque swipe valide (sauf sortie de promo) if(!leavingPromo){ swipeCount = swipeCount + 1; } // Promo cyclique : tous les N swipes (peu importe direction) var goingToPromo = (!leavingPromo && swipeCount > 0 && swipeCount % PROMO_EVERY_N_SWIPES === 0); console.log('[whr-job] swipe direction=' + direction + ' currentIndex=' + currentIndex + ' swipeCount=' + swipeCount + ' goingToPromo=' + goingToPromo + ' leavingPromo=' + leavingPromo); // Sortir de la promo if(leavingPromo){ var lastIdx = parseInt(jobCard.dataset.lastJobIndex || '0', 10); var targetIdx = (direction > 0) ? (lastIdx + 1 + TOTAL) % TOTAL : lastIdx; var targetSlug = SLUGS[targetIdx]; swipeOutWithSkeleton(direction, function(){ loadCardAjax(targetSlug, direction).then(function(data){ if(data){ jobCardContent.innerHTML = buildJobHtml(data); currentIndex = targetIdx; jobCard.dataset.currentSlug = targetSlug; jobCard.dataset.lastJobIndex = String(targetIdx); updateCounter(); bindLikeHandlers(); prefetchNeighbors(); } swipeIn(function(){ isAnimating = false; }); }).catch(function(err){ console.error('[whr-job] error leaving promo', err); swipeIn(function(){ isAnimating = false; }); }); }); return; } // Afficher la promo if(goingToPromo){ console.log('[whr-job] >>> PROMO'); jobCard.dataset.lastJobIndex = String(currentIndex); swipeOutWithSkeleton(direction, function(){ jobCardContent.innerHTML = renderPromoCard(); currentIndex = -1; if(bannerCounter) bannerCounter.textContent = '★'; swipeIn(function(){ isAnimating = false; }); }); return; } // Navigation normale var nextIdx = (currentIndex + direction + TOTAL) % TOTAL; var nextSlug = SLUGS[nextIdx]; swipeOutWithSkeleton(direction, function(){ loadCardAjax(nextSlug, direction).then(function(data){ if(data){ jobCardContent.innerHTML = buildJobHtml(data); currentIndex = nextIdx; jobCard.dataset.currentSlug = nextSlug; jobCard.dataset.lastJobIndex = String(nextIdx); updateCounter(); bindLikeHandlers(); prefetchNeighbors(); } else { console.warn('[whr-job] AJAX failed for slug', nextSlug); } swipeIn(function(){ isAnimating = false; }); }).catch(function(err){ console.error('[whr-job] AJAX error', err); swipeIn(function(){ isAnimating = false; }); }); }); } // Variante de swipeOut qui affiche le skeleton DÈS que la card est sortie function swipeOutWithSkeleton(direction, cb){ if(!jobCard) { if(cb) cb(); return; } jobCard.classList.remove('dragging'); jobCard.style.transform = ''; if(indicatorPrev) indicatorPrev.style.opacity = '0'; if(indicatorNext) indicatorNext.style.opacity = '0'; void jobCard.offsetWidth; var outClass = direction > 0 ? 'swipe-out-left' : 'swipe-out-right'; jobCard.classList.add(outClass); setTimeout(function(){ // Afficher le skeleton dès la sortie de la card (avant le AJAX) jobCardContent.innerHTML = renderSkeleton(); jobCard.classList.remove('swipe-out-left', 'swipe-out-right'); if(cb) cb(); }, 320); } function swipeOut(direction, cb){ if(!jobCard) { if(cb) cb(); return; } // Nettoyer toute transform inline et la classe dragging jobCard.classList.remove('dragging'); jobCard.style.transform = ''; // Cacher les indicateurs visuels du drag if(indicatorPrev) indicatorPrev.style.opacity = '0'; if(indicatorNext) indicatorNext.style.opacity = '0'; // Force un reflow avant d'ajouter la classe pour que la transition redémarre void jobCard.offsetWidth; var outClass = direction > 0 ? 'swipe-out-left' : 'swipe-out-right'; jobCard.classList.add(outClass); setTimeout(function(){ jobCard.classList.remove('swipe-out-left', 'swipe-out-right'); if(cb) cb(); }, 320); } function swipeIn(cb){ if(!jobCard) { if(cb) cb(); return; } // Nettoyer tout transform inline résiduel jobCard.style.transform = ''; jobCard.classList.remove('swipe-in'); // Force un reflow puis lance l'animation void jobCard.offsetWidth; jobCard.classList.add('swipe-in'); setTimeout(function(){ jobCard.classList.remove('swipe-in'); if(cb) cb(); }, 380); } // ═══ Drag & swipe ═══ var SWIPE_THRESHOLD = 100; var startX = 0, startY = 0, currentX = 0, currentY = 0; var isDragging = false, hasMoved = false; function resetCard(){ if(!jobCard) return; jobCard.classList.remove('dragging'); jobCard.style.transform = ''; if(indicatorPrev) indicatorPrev.style.opacity = '0'; if(indicatorNext) indicatorNext.style.opacity = '0'; } function updateDragPos(deltaX, deltaY){ var rotate = deltaX / 30; jobCard.style.transform = 'translate(' + deltaX + 'px,' + deltaY + 'px) rotate(' + rotate + 'deg)'; var ratio = Math.min(Math.abs(deltaX) / SWIPE_THRESHOLD, 1); if(deltaX > 0){ if(indicatorPrev) indicatorPrev.style.opacity = ratio; if(indicatorNext) indicatorNext.style.opacity = '0'; } else if(deltaX < 0){ if(indicatorNext) indicatorNext.style.opacity = ratio; if(indicatorPrev) indicatorPrev.style.opacity = '0'; } else { if(indicatorPrev) indicatorPrev.style.opacity = '0'; if(indicatorNext) indicatorNext.style.opacity = '0'; } } function onPointerDown(e){ if(isAnimating) return; // pas de drag pendant l'animation var target = e.target; if(target.closest('a, button, input')) return; isDragging = true; hasMoved = false; jobCard.classList.remove('intro-pulse'); var pt = e.touches ? e.touches[0] : e; startX = pt.clientX; startY = pt.clientY; jobCard.classList.add('dragging'); } function onPointerMove(e){ if(!isDragging) return; var pt = e.touches ? e.touches[0] : e; currentX = pt.clientX - startX; currentY = pt.clientY - startY; if(Math.abs(currentX) > 6) hasMoved = true; if(Math.abs(currentY) > Math.abs(currentX) * 1.5 && !hasMoved) return; if(e.cancelable && hasMoved && Math.abs(currentX) > 12) e.preventDefault(); updateDragPos(currentX, currentY * 0.3); } function onPointerUp(){ if(!isDragging) return; isDragging = false; if(Math.abs(currentX) > SWIPE_THRESHOLD){ if(currentX > 0){ // Swipe droite → précédent (AJAX) navigateAjax(-1); } else { // Swipe gauche → suivant (AJAX) navigateAjax(1); } } else { resetCard(); } currentX = 0; currentY = 0; if(indicatorPrev) indicatorPrev.style.opacity = '0'; if(indicatorNext) indicatorNext.style.opacity = '0'; } if(jobCard){ jobCard.addEventListener('touchstart', onPointerDown, {passive:true}); jobCard.addEventListener('touchmove', onPointerMove, {passive:false}); jobCard.addEventListener('touchend', onPointerUp); jobCard.addEventListener('touchcancel', onPointerUp); jobCard.addEventListener('mousedown', onPointerDown); document.addEventListener('mousemove', onPointerMove); document.addEventListener('mouseup', onPointerUp); } // ═══ Raccourcis clavier ═══ document.addEventListener('keydown', function(e){ var tag = (e.target && e.target.tagName) ? e.target.tagName.toUpperCase() : ''; if(tag === 'INPUT' || tag === 'TEXTAREA') return; if(e.key === 'ArrowLeft' && TOTAL > 1){ e.preventDefault(); navigateAjax(-1); } else if(e.key === 'ArrowRight' && TOTAL > 1){ e.preventDefault(); navigateAjax(1); } }); // ═══ Table of contents — scroll spy ═══ var tocLinks = document.querySelectorAll('.side-toc-link'); if(tocLinks.length){ tocLinks.forEach(function(link){ link.addEventListener('click', function(e){ e.preventDefault(); var targetId = link.getAttribute('data-target'); var target = document.getElementById(targetId); if(target) target.scrollIntoView({behavior:'smooth', block:'start'}); }); }); var sections = []; tocLinks.forEach(function(link){ var id = link.getAttribute('data-target'); var el = document.getElementById(id); if(el) sections.push({id:id, el:el, link:link}); }); function updateActiveTocLink(){ var scrollPos = window.scrollY + 100; var current = sections[0]; sections.forEach(function(s){ if(s.el.offsetTop <= scrollPos) current = s; }); tocLinks.forEach(function(l){ l.classList.remove('active'); }); if(current) current.link.classList.add('active'); } window.addEventListener('scroll', updateActiveTocLink, {passive:true}); updateActiveTocLink(); } // Init counter updateCounter(); prefetchNeighbors(); // Stop intro-pulse au premier interaction utilisateur ['mousedown','touchstart','keydown'].forEach(function(evt){ document.addEventListener(evt, function(){ if(jobCard) jobCard.classList.remove('intro-pulse'); }, {once:true}); }); console.log('[whr-job] init done', {TOTAL: TOTAL, SLUGS: SLUGS}); } catch(err){ console.error('[whr-job] init error:', err); } })(); </script>{% endblock footerjs %}