src/Controller/ThemesWebsite/Whileresume/Application/JobsController.php line 262

Open in your IDE?
  1. <?php
  2. namespace App\Controller\ThemesWebsite\Whileresume\Application;
  3. use App\Entity\Core\Users;
  4. use App\Entity\Cvs\Candidates;
  5. use App\Entity\Cvs\CandidatesHasExperiences;
  6. use App\Entity\Cvs\CandidatesHasProjects;
  7. use App\Entity\Cvs\CandidatesHasServices;
  8. use App\Entity\Cvs\CandidatesHasSkills;
  9. use App\Entity\Cvs\Jobs;
  10. use App\Entity\Cvs\JobsHasLikes;
  11. use App\Form\Core\UsersType;
  12. use App\Form\Cvs\JobsForm;
  13. use App\Form\Cvs\JobsFrForm;
  14. use App\Services\Core\RequestData;
  15. use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
  16. use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
  17. use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
  18. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  19. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  20. use Symfony\Component\HttpFoundation\Request;
  21. use Symfony\Component\HttpFoundation\Response;
  22. use Symfony\Component\Routing\Annotation\Route;
  23. use Symfony\Component\HttpFoundation\Cookie;
  24. use Symfony\Component\HttpFoundation\JsonResponse;
  25. use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
  26. use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
  27. use Doctrine\ORM\EntityManagerInterface;
  28. class JobsController extends AbstractController
  29. {
  30.     private $rd;
  31.     private $em;
  32.     public function __construct(RequestData $rd,
  33.                                 EntityManagerInterface $em
  34.     ) {
  35.         $this->rd $rd;
  36.         $this->em $em;
  37.     }
  38.     public function new(Request $request): Response
  39.     {
  40.         $locale $request->getLocale();
  41.         $user $this->getUser();
  42.         $jobClass JobsForm::class;
  43.         if($locale == "fr") {
  44.             $jobClass JobsFrForm::class;
  45.         }
  46.         $job = new Jobs();
  47.         $form $this->createForm($jobClass$job);
  48.         $form->handleRequest($request);
  49.         if ($form->isSubmitted() && $form->isValid()) {
  50.             $job->setSlug("0");
  51.             $job->setLocale($locale);
  52.             $job->setValidation(false);
  53.             $job->setVerification(false);
  54.             $job->setOnline(false);
  55.             $job->setEnterprise(null);
  56.             $job->setUser($user);
  57.             $this->em->persist($job);
  58.             $this->em->flush();
  59.             if($job->getSlug() != "0") {
  60.                 $job->setSlug($this->generateSlug($job->getId()));
  61.                 $this->em->persist($job);
  62.                 $this->em->flush();
  63.             }
  64.             if($locale !== "en") {
  65.                 return $this->redirectToRoute('locale_cvs_application_job_new_confirm',['_locale' => $locale]);
  66.             }
  67.             return $this->redirectToRoute('cvs_application_job_new_confirm');
  68.         }
  69.         return $this->render('application/whileresume/application/jobs/new_'.$locale.'.html.twig',[
  70.             'form' => $form->createView(),
  71.         ]);
  72.     }
  73.     private function generateSlug(int $id): string
  74.     {
  75.         $letters 'abcdefghijklmnopqrstuvwxyz';
  76.         $random '';
  77.         for ($i 0$i 6$i++) {
  78.             $random .= $letters[random_int(025)];
  79.         }
  80.         return $random '-' $id;
  81.     }
  82.     public function show(Request $request$slug): Response
  83.     {
  84.         $locale $request->getLocale();
  85.         $jobsRepo $this->em->getRepository(Jobs::class);
  86.         $job $jobsRepo->findOneBy(['locale' => $locale'slug' => $slug'online' => true'validation' => true]);
  87.         if($job === null) {
  88.             throw $this->createNotFoundException('Page non trouvée');
  89.         }
  90.         $othersJobs null;
  91.         if($job->getEnterprise() != null) {
  92.             $othersJobs $jobsRepo->findBy(['enterprise' => $job->getEnterprise(), 'online' => true]);
  93.         }
  94.         // Offres similaires basées sur catégorie, compétences, ville, titre
  95.         $similarJobs $jobsRepo->findSimilarJobs($job5);
  96.         // Filtres sidebar : tags polymorphes (villes + métiers) configurés en BDD
  97.         $filtersRepo $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class);
  98.         $sidebarFilters $filtersRepo->findActiveByLocale($locale30);
  99.         // Séparer par type pour l'affichage (city vs category)
  100.         $sidebarFiltersCities = [];
  101.         $sidebarFiltersCategories = [];
  102.         foreach ($sidebarFilters as $f) {
  103.             if ($f->getType() === 'city') {
  104.                 $sidebarFiltersCities[] = $f;
  105.             } else {
  106.                 $sidebarFiltersCategories[] = $f;
  107.             }
  108.         }
  109.         // Keywords cliquables sous la barre de recherche (homepage de la locale)
  110.         $keywordsRepo $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class);
  111.         $searchKeywords $keywordsRepo->findByLocaleForHomepage($locale);
  112.         $count 0;
  113.         if($job->getViews() != null) {
  114.             $count $job->getViews() + 1;
  115.         }
  116.         $job->setViews($count);
  117.         $this->em->persist($job);
  118.         $this->em->flush();
  119.         // État du like pour le candidat connecté (rendu initial du bouton)
  120.         $isLiked false;
  121.         $currentUser $this->getUser();
  122.         if ($currentUser !== null && $currentUser->getCandidate() !== null) {
  123.             $existingLike $this->em->getRepository(JobsHasLikes::class)
  124.                 ->findOneBy(['candidate' => $currentUser->getCandidate(), 'job' => $job]);
  125.             $isLiked $existingLike !== null;
  126.         }
  127.         return $this->render('application/whileresume/application/jobs/show.html.twig',[
  128.             'slug' => $slug,
  129.             'job' =>  $job,
  130.             'othersJobs' => $othersJobs,
  131.             'similarJobs' => $similarJobs,
  132.             'sidebarFiltersCities' => $sidebarFiltersCities,
  133.             'sidebarFiltersCategories' => $sidebarFiltersCategories,
  134.             'searchKeywords' => $searchKeywords,
  135.             'isLiked' => $isLiked,
  136.         ]);
  137.     }
  138.     /**
  139.      * Retourne les détails d'une offre en JSON pour la fiche modale (AJAX).
  140.      * Si l'utilisateur n'est pas connecté, retourne uniquement les champs publics
  141.      * (résumé + meta) avec un flag "locked" sur les détails.
  142.      */
  143.     public function ajaxShow(Request $request$slug): JsonResponse
  144.     {
  145.         $locale $request->getLocale();
  146.         $user $this->getUser();
  147.         $job $this->em->getRepository(Jobs::class)->findOneBy([
  148.             'locale' => $locale,
  149.             'slug' => $slug,
  150.             'online' => true,
  151.             'validation' => true
  152.         ]);
  153.         if ($job === null) {
  154.             return new JsonResponse(['error' => 'not_found'], 404);
  155.         }
  156.         $isAuthenticated $user !== null;
  157.         $isCandidate $isAuthenticated && $user->getCandidate() !== null;
  158.         $enterpriseData null;
  159.         if ($job->getEnterprise() !== null) {
  160.             $enterpriseData = [
  161.                 'id' => $job->getEnterprise()->getId(),
  162.                 'slug' => $job->getEnterprise()->getSlug(),
  163.                 'name' => $job->getEnterprise()->getCompanyName(),
  164.                 'logo' => method_exists($job->getEnterprise(), 'getLogo') ? $job->getEnterprise()->getLogo() : null,
  165.                 'city' => method_exists($job->getEnterprise(), 'getCity') ? $job->getEnterprise()->getCity() : null,
  166.                 'country' => method_exists($job->getEnterprise(), 'getCountry') ? $job->getEnterprise()->getCountry() : null,
  167.             ];
  168.         }
  169.         // Données publiques (toujours visibles)
  170.         $payload = [
  171.             'id' => $job->getId(),
  172.             'slug' => $job->getSlug(),
  173.             'title' => $job->getJobTitle(),
  174.             'companyName' => $job->getCompanyName(),
  175.             'category' => $job->getCategory(),
  176.             'city' => $job->getCity(),
  177.             'country' => $job->getCountry(),
  178.             'employmentType' => $job->getEmploymentType(),
  179.             'remoteWork' => $job->getRemoteWork(),
  180.             'experienceLevel' => $job->getExperienceLevel(),
  181.             'salaryMin' => $job->getSalaryMin(),
  182.             'salaryMax' => $job->getSalaryMax(),
  183.             'salaryPeriod' => $job->getSalaryPeriod(),
  184.             'devise' => $job->getDevise(),
  185.             'jobSummary' => $job->getJobSummary(),
  186.             'verification' => $job->isVerification(),
  187.             'website' => method_exists($job'getWebsite') ? $job->getWebsite() : null,
  188.             'websearch' => method_exists($job'getWebsearch') ? $job->getWebsearch() : null,
  189.             'enterprise' => $enterpriseData,
  190.             'isAuthenticated' => $isAuthenticated,
  191.             'isCandidate' => $isCandidate,
  192.             'locked' => !$isAuthenticated,
  193.             'showUrl' => $locale === 'en'
  194.                 $this->generateUrl('cvs_application_job_show', ['slug' => $slug])
  195.                 : $this->generateUrl('locale_cvs_application_job_show', ['_locale' => $locale'slug' => $slug]),
  196.             'likeUrl' => null,
  197.         ];
  198.         // Données détaillées (uniquement si connecté)
  199.         if ($isAuthenticated) {
  200.             $payload['keyResponsabilities'] = $job->getKeyResponsabilities();
  201.             $payload['requirements'] = $job->getRequirements();
  202.             $payload['benefits'] = $job->getBenefits();
  203.             if ($isCandidate) {
  204.                 $payload['likeUrl'] = $locale === 'en'
  205.                     $this->generateUrl('cvs_application_job_like', ['slug' => $slug])
  206.                     : $this->generateUrl('locale_cvs_application_job_like', ['_locale' => $locale'slug' => $slug]);
  207.                 // État du like pour ce candidat (utilisé par buildJobHtml côté JS swipe)
  208.                 $existingLike $this->em->getRepository(JobsHasLikes::class)
  209.                     ->findOneBy(['candidate' => $user->getCandidate(), 'job' => $job]);
  210.                 $payload['isLiked'] = $existingLike !== null;
  211.             } else {
  212.                 $payload['isLiked'] = false;
  213.             }
  214.         } else {
  215.             $payload['isLiked'] = false;
  216.         }
  217.         return new JsonResponse($payload);
  218.     }
  219.     /**
  220.      * Page dashboard / liste publique des offres.
  221.      * URL : /{locale}/jobs
  222.      *
  223.      * Affiche la sidebar Sociala (filtres villes/métiers + chips keywords)
  224.      * + une liste des offres alimentée en AJAX via apiSearch (avec "Charger plus").
  225.      */
  226.     public function dashboard(Request $request): Response
  227.     {
  228.         $locale $request->getLocale();
  229.         // Accepte ?q= (legacy) ou ?search= (nouveau, pour les fallbacks depuis les pages ville)
  230.         $initialQuery trim((string) $request->query->get('search'$request->query->get('q''')));
  231.         // Filtres sidebar (villes / métiers)
  232.         $filtersRepo $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class);
  233.         $sidebarFilters $filtersRepo->findActiveByLocale($locale30);
  234.         $sidebarFiltersCities = [];
  235.         $sidebarFiltersCategories = [];
  236.         foreach ($sidebarFilters as $f) {
  237.             if ($f->getType() === 'city') {
  238.                 $sidebarFiltersCities[] = $f;
  239.             } else {
  240.                 $sidebarFiltersCategories[] = $f;
  241.             }
  242.         }
  243.         // Keywords cliquables sous la barre de recherche
  244.         $keywordsRepo $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class);
  245.         $searchKeywords $keywordsRepo->findByLocaleForHomepage($locale);
  246.         return $this->render('application/whileresume/application/jobs/dashboard.html.twig', [
  247.             'initialQuery'              => $initialQuery,
  248.             'sidebarFiltersCities'      => $sidebarFiltersCities,
  249.             'sidebarFiltersCategories'  => $sidebarFiltersCategories,
  250.             'searchKeywords'            => $searchKeywords,
  251.             'currentCityFilter'         => null,
  252.             'currentCityKeyword'        => '',
  253.             'currentSubKeyword'         => null,
  254.             'currentSubKeywordSlug'     => null,
  255.         ]);
  256.     }
  257.     /**
  258.      * Page landing pour un filtre (ville/métier/etc.) configuré dans cvs_jobsfilters.
  259.      * URL : /{locale}/jobs/{slug} → ex: /fr/jobs/paris ou /fr/jobs/developpement-web
  260.      *
  261.      * Rend le même template que le dashboard, avec :
  262.      *  - le filtre actif marqué (chip violet plein)
  263.      *  - le searchKeyword pré-rempli dans la barre de recherche
  264.      *  - la liste pré-filtrée sur ce keyword
  265.      */
  266.     public function filterLanding(Request $request$slug): Response
  267.     {
  268.         $locale $request->getLocale();
  269.         $filtersRepo $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class);
  270.         $filter $filtersRepo->findOneBySlugAndLocale($slug$locale);
  271.         if ($filter === null) {
  272.             throw $this->createNotFoundException('Filtre introuvable');
  273.         }
  274.         // Le keyword utilisé pour la recherche (pré-rempli dans la barre)
  275.         $keyword $filter->getSearchKeyword() ?: $filter->getLabel();
  276.         // Filtres sidebar
  277.         $sidebarFilters $filtersRepo->findActiveByLocale($locale30);
  278.         $sidebarFiltersCities = [];
  279.         $sidebarFiltersCategories = [];
  280.         foreach ($sidebarFilters as $f) {
  281.             if ($f->getType() === 'city') {
  282.                 $sidebarFiltersCities[] = $f;
  283.             } else {
  284.                 $sidebarFiltersCategories[] = $f;
  285.             }
  286.         }
  287.         // Keywords cliquables sous la barre de recherche
  288.         $keywordsRepo $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class);
  289.         $searchKeywords $keywordsRepo->findByLocaleForHomepage($locale);
  290.         // Si le filtre est une VILLE → on contraint les recherches à cette ville
  291.         // Sinon (catégorie/métier) → c'est juste un préfiltre keyword classique
  292.         $isCityContext = ($filter->getType() === 'city');
  293.         // Sur une page ville, on accepte aussi ?search=texte (recherche libre dans la ville)
  294.         $freeSearch $isCityContext trim((string) $request->query->get('search''')) : '';
  295.         $initialQuery $isCityContext
  296.             $freeSearch          // search libre prioritaire sinon vide
  297.             $keyword;            // catégorie/métier → préfiltre par défaut
  298.         // Logger la visite de cette page landing
  299.         try {
  300.             $statsRepo $this->em->getRepository(\App\Entity\Cvs\SearchStats::class);
  301.             if ($isCityContext) {
  302.                 // Page ville : on log la ville (+ recherche libre si fournie)
  303.                 $statsRepo->logSearch($freeSearch$keyword$locale'landing_city');
  304.             } else {
  305.                 // Page catégorie/métier : on log le keyword comme query
  306.                 $statsRepo->logSearch($keywordnull$locale'landing_keyword');
  307.             }
  308.         } catch (\Throwable $e) { /* silent fail */ }
  309.         return $this->render('application/whileresume/application/jobs/dashboard.html.twig', [
  310.             'initialQuery'              => $initialQuery,
  311.             'sidebarFiltersCities'      => $sidebarFiltersCities,
  312.             'sidebarFiltersCategories'  => $sidebarFiltersCategories,
  313.             'searchKeywords'            => $searchKeywords,
  314.             'currentFilter'             => $filter,                        // pour SEO + chip active
  315.             'currentCityFilter'         => $isCityContext $filter null// contrainte ville pour la recherche
  316.             'currentCityKeyword'        => $isCityContext $keyword '',  // keyword utilisé pour matcher j.city
  317.             'currentSubKeyword'         => null,                            // pas de sous-keyword sur cette page
  318.             'currentSubKeywordSlug'     => null,
  319.         ]);
  320.     }
  321.     /**
  322.      * Page combinée ville + keyword : /jobs/{slug}/{keyword}
  323.      * Ex: /fr/jobs/paris/developpeur → toutes les offres "développeur" À PARIS
  324.      *
  325.      * Le {slug} doit correspondre à un filtre de type 'city' actif. Sinon 404.
  326.      * Le {keyword} est un slug libre, qui peut correspondre à un KeywordsLandingJobs ou pas.
  327.      */
  328.     public function filterLandingKeyword(Request $request$slug$keyword): Response
  329.     {
  330.         $locale $request->getLocale();
  331.         $filtersRepo $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class);
  332.         $filter $filtersRepo->findOneBySlugAndLocale($slug$locale);
  333.         if ($filter === null || $filter->getType() !== 'city') {
  334.             throw $this->createNotFoundException('Filtre ville introuvable');
  335.         }
  336.         // Recherche : on combine la ville (contrainte j.city) + le keyword (recherche full-text)
  337.         $cityKeyword $filter->getSearchKeyword() ?: $filter->getLabel();
  338.         // Le keyword peut soit correspondre à un KeywordsLandingJobs (BDD), soit être libre
  339.         $keywordsRepo $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class);
  340.         $searchKeywords $keywordsRepo->findByLocaleForHomepage($locale);
  341.         // Cherche dans les keywords config si le slug en URL matche
  342.         $matchedKeyword null;
  343.         foreach ($searchKeywords as $kw) {
  344.             if ($this->slugify($kw->getLabel()) === $keyword) {
  345.                 $matchedKeyword $kw;
  346.                 break;
  347.             }
  348.         }
  349.         // Si match trouvé : on prend le searchKeyword/label défini ; sinon : on utilise le slug brut comme query
  350.         $subKeywordQuery $matchedKeyword !== null
  351.             ? ($matchedKeyword->getSearchKeyword() ?: $matchedKeyword->getLabel())
  352.             : str_replace('-'' '$keyword);
  353.         // Filtres sidebar
  354.         $sidebarFilters $filtersRepo->findActiveByLocale($locale30);
  355.         $sidebarFiltersCities = [];
  356.         $sidebarFiltersCategories = [];
  357.         foreach ($sidebarFilters as $f) {
  358.             if ($f->getType() === 'city') {
  359.                 $sidebarFiltersCities[] = $f;
  360.             } else {
  361.                 $sidebarFiltersCategories[] = $f;
  362.             }
  363.         }
  364.         // Logger la visite : query = keyword, ville = cityKeyword
  365.         try {
  366.             $statsRepo $this->em->getRepository(\App\Entity\Cvs\SearchStats::class);
  367.             $statsRepo->logSearch($subKeywordQuery$cityKeyword$locale'landing_keyword');
  368.         } catch (\Throwable $e) { /* silent fail */ }
  369.         return $this->render('application/whileresume/application/jobs/dashboard.html.twig', [
  370.             'initialQuery'              => $subKeywordQuery,
  371.             'sidebarFiltersCities'      => $sidebarFiltersCities,
  372.             'sidebarFiltersCategories'  => $sidebarFiltersCategories,
  373.             'searchKeywords'            => $searchKeywords,
  374.             'currentFilter'             => $filter,
  375.             'currentCityFilter'         => $filter,
  376.             'currentCityKeyword'        => $cityKeyword,
  377.             'currentSubKeyword'         => $matchedKeyword !== null $matchedKeyword->getLabel() : ucfirst(str_replace('-'' '$keyword)),
  378.             'currentSubKeywordSlug'     => $keyword,
  379.         ]);
  380.     }
  381.     /**
  382.      * Slugifie une chaîne (ex: "Développeur Web" → "developpeur-web")
  383.      * Utilisé pour matcher les keywords URL avec ceux de la BDD.
  384.      * IMPORTANT : doit produire EXACTEMENT le même slug que le filtre Twig
  385.      * utilisé dans dashboard.html.twig pour générer les liens des chips.
  386.      */
  387.     private function slugify(string $text): string
  388.     {
  389.         // Pré-remplacement des caractères spéciaux SÉMANTIQUES (avant la translitération)
  390.         // pour rester cohérent avec le Twig qui traite '&' → '-and-', '+' → '-plus-',
  391.         // 'œ' → 'oe', 'æ' → 'ae' (Latin-ASCII les remplace souvent par '' tout court)
  392.         $text strtr($text, [
  393.             '&' => '-and-',
  394.             '+' => '-plus-',
  395.             'œ' => 'oe',
  396.             'Œ' => 'oe',
  397.             'æ' => 'ae',
  398.             'Æ' => 'ae',
  399.         ]);
  400.         // Translitération des accents → ASCII + lowercase
  401.         if (function_exists('transliterator_transliterate')) {
  402.             $text transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()'$text);
  403.         } else {
  404.             // Fallback : iconv
  405.             $text strtolower(iconv('UTF-8''ASCII//TRANSLIT//IGNORE'$text));
  406.         }
  407.         // Remplace tout ce qui n'est pas alphanumérique par un tiret
  408.         $text preg_replace('/[^a-z0-9]+/''-'$text);
  409.         // Trim les tirets en début/fin
  410.         return trim($text'-');
  411.     }
  412.     /**
  413.      * API publique de recherche d'offres pour l'autocomplétion.
  414.      * GET /api/jobs/search?q=...&locale=...&limit=...&city=...
  415.      */
  416.     public function apiSearch(Request $request): JsonResponse
  417.     {
  418.         $query trim((string) $request->query->get('q'''));
  419.         $locale $request->query->get('locale'$request->getLocale());
  420.         $limit max(1min(20, (int) $request->query->get('limit'6)));
  421.         $offset max(0, (int) $request->query->get('offset'0));
  422.         $city trim((string) $request->query->get('city'''));
  423.         $cityKeyword $city !== '' $city null;
  424.         // Mode "tout afficher" : utilisé par la page dashboard pour lister
  425.         // les dernières offres quand aucun filtre n'est appliqué.
  426.         // (Les widgets d'autocomplétion ne passent PAS ce flag : ils gardent
  427.         // l'ancien comportement "pas de spam tant que la query fait < 2 chars".)
  428.         $allowEmpty filter_var($request->query->get('all''0'), FILTER_VALIDATE_BOOLEAN);
  429.         // Si on a une ville mais pas de query, on autorise (la ville suffit comme filtre)
  430.         // Si on demande explicitement "toutes les offres" (dashboard), on autorise aussi
  431.         if (mb_strlen($query) < && $cityKeyword === null && !$allowEmpty) {
  432.             return new JsonResponse([
  433.                 'items' => [],
  434.                 'total' => 0,
  435.                 'query' => $query,
  436.             ]);
  437.         }
  438.         $repo $this->em->getRepository(Jobs::class);
  439.         $jobs $repo->searchJobs($query$locale$offset$limit$cityKeyword);
  440.         $total $repo->countSearchJobs($query$locale$cityKeyword);
  441.         // Logger la recherche (uniquement au 1er chargement, pas en pagination)
  442.         if ($offset === 0) {
  443.             try {
  444.                 $statsRepo $this->em->getRepository(\App\Entity\Cvs\SearchStats::class);
  445.                 $statsRepo->logSearch($query$cityKeyword$locale'submit');
  446.             } catch (\Throwable $e) { /* silent fail */ }
  447.         }
  448.         $items = [];
  449.         foreach ($jobs as $job) {
  450.             $logo null;
  451.             if ($job->getEnterprise() !== null && method_exists($job->getEnterprise(), 'getLogo')) {
  452.                 $logo $job->getEnterprise()->getLogo();
  453.             }
  454.             $items[] = [
  455.                 'id' => $job->getId(),
  456.                 'slug' => $job->getSlug(),
  457.                 'title' => $job->getJobTitle(),
  458.                 'companyName' => $job->getCompanyName(),
  459.                 'category' => $job->getCategory(),
  460.                 'city' => $job->getCity(),
  461.                 'country' => $job->getCountry(),
  462.                 'employmentType' => $job->getEmploymentType(),
  463.                 'salaryMin' => $job->getSalaryMin(),
  464.                 'salaryMax' => $job->getSalaryMax(),
  465.                 'devise' => $job->getDevise(),
  466.                 'logo' => $logo,
  467.                 'url' =>  $locale === 'en'
  468.                     $this->generateUrl('cvs_application_job_show', ['slug' => $job->getSlug()])
  469.                     : $this->generateUrl('locale_cvs_application_job_show', ['_locale' => $locale'slug' => $job->getSlug()]),
  470.             ];
  471.         }
  472.         return new JsonResponse([
  473.             'items' => $items,
  474.             'total' => $total,
  475.             'query' => $query,
  476.             'city' => $cityKeyword,
  477.             'limit' => $limit,
  478.             'offset' => $offset,
  479.         ]);
  480.     }
  481.     /**
  482.      * Like / unlike (toggle) d'une offre par le candidat connecté.
  483.      *
  484.      * - Requête AJAX (X-Requested-With: XMLHttpRequest ou Accept: application/json)
  485.      *   → retourne JSON {success, liked, jobId, slug}
  486.      * - Requête classique (GET direct depuis un <a>)
  487.      *   → comportement legacy : redirection vers le dashboard candidat
  488.      *
  489.      * Cas d'erreur AJAX :
  490.      *   - 401 unauthenticated     : utilisateur non connecté
  491.      *   - 403 no_candidate_profile: connecté mais sans profil candidat
  492.      *   - 404 not_found           : offre introuvable
  493.      */
  494.     public function likeJob(Request $request$slug): Response
  495.     {
  496.         $locale $request->getLocale();
  497.         $user $this->getUser();
  498.         $isAjax $request->isXmlHttpRequest()
  499.             || str_contains((string) $request->headers->get('Accept'), 'application/json');
  500.         // Non connecté
  501.         if ($user === null) {
  502.             if ($isAjax) {
  503.                 return new JsonResponse([
  504.                     'success'  => false,
  505.                     'error'    => 'unauthenticated',
  506.                     'loginUrl' => $locale === 'en'
  507.                         $this->generateUrl('app_login')
  508.                         : $this->generateUrl('locale_app_login', ['_locale' => $locale]),
  509.                 ], 401);
  510.             }
  511.             return $locale === 'en'
  512.                 $this->redirectToRoute('cvs_gestion_candidates_dashboard')
  513.                 : $this->redirectToRoute('locale_cvs_gestion_candidates_dashboard', ['_locale' => $locale]);
  514.         }
  515.         $candidate $user->getCandidate();
  516.         $job $this->em->getRepository(Jobs::class)->findOneBy(['slug' => $slug]);
  517.         if ($job === null) {
  518.             if ($isAjax) {
  519.                 return new JsonResponse(['success' => false'error' => 'not_found'], 404);
  520.             }
  521.             throw $this->createNotFoundException('Offre introuvable');
  522.         }
  523.         if ($candidate === null) {
  524.             if ($isAjax) {
  525.                 return new JsonResponse(['success' => false'error' => 'no_candidate_profile'], 403);
  526.             }
  527.             return $locale === 'en'
  528.                 $this->redirectToRoute('cvs_gestion_candidates_dashboard')
  529.                 : $this->redirectToRoute('locale_cvs_gestion_candidates_dashboard', ['_locale' => $locale]);
  530.         }
  531.         $repo $this->em->getRepository(JobsHasLikes::class);
  532.         $jhl  $repo->findOneBy(['candidate' => $candidate'job' => $job]);
  533.         if ($jhl === null) {
  534.             // LIKE
  535.             $jhl = new JobsHasLikes();
  536.             $jhl->setJob($job);
  537.             $jhl->setCandidate($candidate);
  538.             $jhl->setCreatedAt(new \DateTime('now'));
  539.             $jhl->setUpdatedAt(new \DateTime('now'));
  540.             $this->em->persist($jhl);
  541.             $this->em->flush();
  542.             $liked true;
  543.         } else {
  544.             // UNLIKE (toggle)
  545.             $this->em->remove($jhl);
  546.             $this->em->flush();
  547.             $liked false;
  548.         }
  549.         if ($isAjax) {
  550.             return new JsonResponse([
  551.                 'success' => true,
  552.                 'liked'   => $liked,
  553.                 'jobId'   => $job->getId(),
  554.                 'slug'    => $job->getSlug(),
  555.             ]);
  556.         }
  557.         // Comportement legacy non-AJAX : redirection dashboard candidat
  558.         return $locale === 'en'
  559.             $this->redirectToRoute('cvs_gestion_candidates_dashboard')
  560.             : $this->redirectToRoute('locale_cvs_gestion_candidates_dashboard', ['_locale' => $locale]);
  561.     }
  562. }