src/Controller/ThemesWebsite/Whileresume/Application/EnterprisesController.php line 332

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\Enterprises;
  6. use App\Entity\Cvs\EnterprisesHasLikes;
  7. use App\Entity\Cvs\Jobs;
  8. use App\Entity\Cvs\JobsHasLikes;
  9. use App\Services\Core\RequestData;
  10. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  11. use Symfony\Component\HttpFoundation\JsonResponse;
  12. use Symfony\Component\HttpFoundation\Request;
  13. use Symfony\Component\HttpFoundation\Response;
  14. use Symfony\Component\HttpFoundation\RedirectResponse;
  15. use Symfony\Component\Routing\Annotation\Route;
  16. use Doctrine\ORM\EntityManagerInterface;
  17. use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
  18. class EnterprisesController extends AbstractController
  19. {
  20.     private $rd;
  21.     private $em;
  22.     private $uploaderHelper;
  23.     public function __construct(RequestData $rdEntityManagerInterface $emUploaderHelper $uploaderHelper) {
  24.         $this->rd $rd;
  25.         $this->em $em;
  26.         $this->uploaderHelper $uploaderHelper;
  27.     }
  28.     /**
  29.      * Page individuelle d'une entreprise — fiche détaillée + offres + similaires + sidebar.
  30.      * URL : /{locale}/company/{slug}
  31.      */
  32.     public function show(Request $request$slug): Response
  33.     {
  34.         $locale $request->getLocale();
  35.         $enterprise $this->em->getRepository(Enterprises::class)->findOneBy([
  36.             'slug' => $slug,
  37.             'locale' => $locale,
  38.             'online' => true
  39.         ]);
  40.         if ($enterprise === null) {
  41.             throw $this->createNotFoundException('Entreprise introuvable');
  42.         }
  43.         // Offres de cette entreprise
  44.         $jobs $this->em->getRepository(Jobs::class)->findBy([
  45.             'enterprise' => $enterprise,
  46.             'online' => true
  47.         ], ['createdAt' => 'DESC']);
  48.         // Entreprises similaires (même catégorie ou même ville, max 5)
  49.         $similarEnterprises $this->em->getRepository(Enterprises::class)
  50.             ->findSimilar($enterprise5);
  51.         // Incrémente le compteur de vues
  52.         try {
  53.             $count $enterprise->getViews() ?? 0;
  54.             $enterprise->setViews($count 1);
  55.             $this->em->persist($enterprise);
  56.             $this->em->flush();
  57.         } catch (\Throwable $e) {
  58.             // Silent fail : on ne bloque pas l'affichage si le compteur de vues plante
  59.         }
  60.         // Filtres sidebar (depuis EnterprisesFilters — dédiés au scope entreprises)
  61.         $sidebarFiltersCities = [];
  62.         $sidebarFiltersCategories = [];
  63.         try {
  64.             $filtersRepo $this->em->getRepository(\App\Entity\Cvs\EnterprisesFilters::class);
  65.             $sidebarFilters $filtersRepo->findActiveByLocale($locale30);
  66.             foreach ($sidebarFilters as $f) {
  67.                 if ($f->getType() === 'city') {
  68.                     $sidebarFiltersCities[] = $f;
  69.                 } else {
  70.                     $sidebarFiltersCategories[] = $f;
  71.                 }
  72.             }
  73.         } catch (\Throwable $e) {
  74.             // Si EnterprisesFilters n'est pas dispo, on continue sans
  75.         }
  76.         // ───────────────────────────────────────────────────────────────────
  77.         // Détection du type de viewer : 'anonymous' | 'candidate' | 'recruiter'
  78.         // ───────────────────────────────────────────────────────────────────
  79.         $viewerType $this->getViewerType();
  80.         $candidate  $this->getCurrentCandidate();
  81.         // isLiked + likedJobIds : seulement pertinents pour un candidat connecté
  82.         $isLiked     false;
  83.         $likedJobIds = [];
  84.         if ($viewerType === 'candidate' && $candidate !== null) {
  85.             // Like entreprise
  86.             $isLiked $this->em->getRepository(EnterprisesHasLikes::class)->findOneBy([
  87.                     'candidate'  => $candidate,
  88.                     'enterprise' => $enterprise,
  89.                 ]) !== null;
  90.             // Likes des jobs de cette entreprise (pour les mini-cœurs)
  91.             if (!empty($jobs)) {
  92.                 $jobLikes $this->em->getRepository(JobsHasLikes::class)->createQueryBuilder('jhl')
  93.                     ->select('IDENTITY(jhl.job) AS jobId')
  94.                     ->where('jhl.candidate = :candidate')
  95.                     ->andWhere('jhl.job IN (:jobs)')
  96.                     ->setParameter('candidate'$candidate)
  97.                     ->setParameter('jobs'$jobs)
  98.                     ->getQuery()
  99.                     ->getArrayResult();
  100.                 $likedJobIds array_map('intval'array_column($jobLikes'jobId'));
  101.             }
  102.         }
  103.         return $this->render('application/whileresume/application/enterprises/show.html.twig', [
  104.             'slug'                      => $slug,
  105.             'enterprise'                => $enterprise,
  106.             'jobs'                      => $jobs,
  107.             'similarEnterprises'        => $similarEnterprises,
  108.             'sidebarFiltersCities'      => $sidebarFiltersCities,
  109.             'sidebarFiltersCategories'  => $sidebarFiltersCategories,
  110.             'viewerType'                => $viewerType,         // 'anonymous' | 'candidate' | 'recruiter'
  111.             'isLiked'                   => $isLiked,
  112.             'likedJobIds'               => $likedJobIds,
  113.             'connectUser'               => $this->getUser() !== null,
  114.             // Backward-compat : on garde isCandidateUser pour ne pas casser d'autres templates
  115.             'isCandidateUser'           => $viewerType === 'candidate',
  116.         ]);
  117.     }
  118.     /**
  119.      * Toggle like/unlike d'une entreprise par un candidat.
  120.      * URL : /{locale}/company/{slug}/like
  121.      *
  122.      * Comportement :
  123.      *  - Anonyme        → redirect vers show (ou JSON 401 si AJAX)
  124.      *  - Recruteur      → 403 (action réservée aux candidats)
  125.      *  - Candidat       → toggle, puis redirect (HTML) ou JSON {liked: bool} (AJAX)
  126.      */
  127.     public function companyLike(Request $request$slug): Response
  128.     {
  129.         $locale  $request->getLocale();
  130.         $isAjax  $request->isXmlHttpRequest();
  131.         $enterprise $this->em->getRepository(Enterprises::class)->findOneBy([
  132.             'slug'   => $slug,
  133.             'locale' => $locale,
  134.             'online' => true,
  135.         ]);
  136.         if ($enterprise === null) {
  137.             if ($isAjax) {
  138.                 return new JsonResponse(['error' => 'enterprise_not_found'], 404);
  139.             }
  140.             throw $this->createNotFoundException('Entreprise introuvable');
  141.         }
  142.         $viewerType $this->getViewerType();
  143.         // Recruteur connecté → action interdite
  144.         if ($viewerType === 'recruiter') {
  145.             if ($isAjax) {
  146.                 return new JsonResponse(['error' => 'forbidden_recruiter'], 403);
  147.             }
  148.             return $this->redirectToShow($locale$slug);
  149.         }
  150.         // Anonyme → redirect vers show (la modal d'auth s'ouvrira via JS)
  151.         if ($viewerType === 'anonymous') {
  152.             if ($isAjax) {
  153.                 return new JsonResponse(['error' => 'unauthenticated'], 401);
  154.             }
  155.             return $this->redirectToShow($locale$slug);
  156.         }
  157.         // → viewerType === 'candidate'
  158.         $candidate $this->getCurrentCandidate();
  159.         $likesRepo $this->em->getRepository(EnterprisesHasLikes::class);
  160.         $existing  $likesRepo->findOneBy([
  161.             'candidate'  => $candidate,
  162.             'enterprise' => $enterprise,
  163.         ]);
  164.         $nowLiked false;
  165.         if ($existing !== null) {
  166.             $this->em->remove($existing);
  167.             $nowLiked false;
  168.         } else {
  169.             $like = new EnterprisesHasLikes();
  170.             $like->setCandidate($candidate);
  171.             $like->setEnterprise($enterprise);
  172.             $this->em->persist($like);
  173.             $nowLiked true;
  174.         }
  175.         $this->em->flush();
  176.         if ($isAjax) {
  177.             return new JsonResponse(['liked' => $nowLiked]);
  178.         }
  179.         return $this->redirectToShow($locale$slug);
  180.     }
  181.     /**
  182.      * Toggle like/unlike d'un job depuis la fiche entreprise (AJAX uniquement).
  183.      * URL : /{locale}/company/{slug}/job/{jobId}/like
  184.      *
  185.      * Endpoint dédié pour les mini-cœurs sur les cartes job de la fiche entreprise.
  186.      * On ne réutilise pas JobsController::likeJob qui n'est pas un toggle et redirige
  187.      * vers le dashboard candidat.
  188.      */
  189.     public function jobLikeAjax(Request $request$slugint $jobId): JsonResponse
  190.     {
  191.         $viewerType $this->getViewerType();
  192.         if ($viewerType === 'anonymous') {
  193.             return new JsonResponse(['error' => 'unauthenticated'], 401);
  194.         }
  195.         if ($viewerType === 'recruiter') {
  196.             return new JsonResponse(['error' => 'forbidden_recruiter'], 403);
  197.         }
  198.         $candidate $this->getCurrentCandidate();
  199.         $job $this->em->getRepository(Jobs::class)->find($jobId);
  200.         if ($job === null) {
  201.             return new JsonResponse(['error' => 'job_not_found'], 404);
  202.         }
  203.         $likesRepo $this->em->getRepository(JobsHasLikes::class);
  204.         $existing  $likesRepo->findOneBy([
  205.             'candidate' => $candidate,
  206.             'job'       => $job,
  207.         ]);
  208.         $nowLiked false;
  209.         if ($existing !== null) {
  210.             $this->em->remove($existing);
  211.             $nowLiked false;
  212.         } else {
  213.             $like = new JobsHasLikes();
  214.             $like->setCandidate($candidate);
  215.             $like->setJob($job);
  216.             $this->em->persist($like);
  217.             $nowLiked true;
  218.         }
  219.         $this->em->flush();
  220.         return new JsonResponse([
  221.             'liked' => $nowLiked,
  222.             'jobId' => $jobId,
  223.         ]);
  224.     }
  225.     /**
  226.      * Helper : redirect vers la fiche entreprise courante (gestion locale FR/EN).
  227.      */
  228.     private function redirectToShow(string $localestring $slug): RedirectResponse
  229.     {
  230.         return $this->redirectToRoute(
  231.             $locale === 'en' 'cvs_application_company_show' 'locale_cvs_application_company_show',
  232.             $locale === 'en' ? ['slug' => $slug] : ['_locale' => $locale'slug' => $slug]
  233.         );
  234.     }
  235.     /**
  236.      * Détermine le type de viewer : 'anonymous' | 'candidate' | 'recruiter'.
  237.      *
  238.      *  - anonymous  : pas connecté
  239.      *  - candidate  : connecté ET a un Candidate associé
  240.      *  - recruiter  : connecté MAIS sans Candidate (= recruteur, ou autre rôle non-candidat)
  241.      */
  242.     private function getViewerType(): string
  243.     {
  244.         $user $this->getUser();
  245.         if ($user === null) {
  246.             return 'anonymous';
  247.         }
  248.         return $this->getCurrentCandidate() !== null 'candidate' 'recruiter';
  249.     }
  250.     /**
  251.      * Détermine si l'utilisateur connecté courant est un candidat.
  252.      * @deprecated Utilisez getViewerType() === 'candidate' à la place.
  253.      */
  254.     private function isCandidateUser(): bool
  255.     {
  256.         return $this->getCurrentCandidate() !== null;
  257.     }
  258.     /**
  259.      * Récupère le candidat associé à l'utilisateur connecté (s'il y en a un).
  260.      *
  261.      * Note : la relation traverse depuis Users (qui a un getter getCandidate())
  262.      * et non depuis Candidates (qui n'a pas de propriété 'user' Doctrine).
  263.      */
  264.     private function getCurrentCandidate(): ?Candidates
  265.     {
  266.         $user $this->getUser();
  267.         if (!$user instanceof Users) {
  268.             return null;
  269.         }
  270.         return method_exists($user'getCandidate') ? $user->getCandidate() : null;
  271.     }
  272.     // ═══════════════════════════════════════════════════════════════════
  273.     // DASHBOARD /companies — liste publique avec recherche + filtres
  274.     // ═══════════════════════════════════════════════════════════════════
  275.     /**
  276.      * Dashboard public /companies — liste avec recherche AJAX + chips villes/métiers.
  277.      */
  278.     public function dashboard(Request $request): Response
  279.     {
  280.         $locale $request->getLocale();
  281.         // Accepte ?q= ou ?search=
  282.         $initialQuery trim((string) $request->query->get('search'$request->query->get('q''')));
  283.         // Filtres sidebar (depuis EnterprisesFilters — dédiés au scope entreprises)
  284.         $sidebarFiltersCities = [];
  285.         $sidebarFiltersCategories = [];
  286.         $searchKeywords = [];
  287.         try {
  288.             $filtersRepo $this->em->getRepository(\App\Entity\Cvs\EnterprisesFilters::class);
  289.             $sidebarFilters $filtersRepo->findActiveByLocale($locale30);
  290.             foreach ($sidebarFilters as $f) {
  291.                 if ($f->getType() === 'city') {
  292.                     $sidebarFiltersCities[] = $f;
  293.                 } else {
  294.                     $sidebarFiltersCategories[] = $f;
  295.                 }
  296.             }
  297.             $keywordsRepo $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class);
  298.             $searchKeywords $keywordsRepo->findByLocaleForHomepage($locale);
  299.         } catch (\Throwable $e) {
  300.             // OK si pas dispo
  301.         }
  302.         return $this->render('application/whileresume/application/enterprises/dashboard.html.twig', [
  303.             'initialQuery'              => $initialQuery,
  304.             'sidebarFiltersCities'      => $sidebarFiltersCities,
  305.             'sidebarFiltersCategories'  => $sidebarFiltersCategories,
  306.             'searchKeywords'            => $searchKeywords,
  307.             'currentFilter'             => null,
  308.             'currentCityFilter'         => null,
  309.             'currentCityKeyword'        => '',
  310.             'currentCategoryFilter'     => null,
  311.             'currentCategoryKeyword'    => '',
  312.             'currentSubKeyword'         => null,
  313.             'currentSubKeywordSlug'     => null,
  314.             'connectUser'               => $this->getUser() !== null,
  315.             'isCandidateUser'           => $this->isCandidateUser(),
  316.         ]);
  317.     }
  318.     /**
  319.      * Page landing /companies/{slug} : ville (scope j.city) ou catégorie (scope e.category).
  320.      */
  321.     public function filterLanding(Request $request$slug): Response
  322.     {
  323.         $locale $request->getLocale();
  324.         $filtersRepo $this->em->getRepository(\App\Entity\Cvs\EnterprisesFilters::class);
  325.         $filter $filtersRepo->findOneBySlugAndLocale($slug$locale);
  326.         if ($filter === null) {
  327.             throw $this->createNotFoundException('Filtre introuvable');
  328.         }
  329.         $keyword $filter->getSearchKeyword() ?: $filter->getLabel();
  330.         $isCityContext = ($filter->getType() === 'city');
  331.         $isCategoryContext = !$isCityContext;
  332.         // Sur une page ville/catégorie, on accepte ?search=texte (recherche libre dans le scope)
  333.         $freeSearch trim((string) $request->query->get('search'''));
  334.         $initialQuery $freeSearch;
  335.         // Sidebar filters
  336.         $sidebarFiltersCities = [];
  337.         $sidebarFiltersCategories = [];
  338.         $searchKeywords = [];
  339.         try {
  340.             $sidebarFilters $filtersRepo->findActiveByLocale($locale30);
  341.             foreach ($sidebarFilters as $f) {
  342.                 if ($f->getType() === 'city') {
  343.                     $sidebarFiltersCities[] = $f;
  344.                 } else {
  345.                     $sidebarFiltersCategories[] = $f;
  346.                 }
  347.             }
  348.             $keywordsRepo $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class);
  349.             $searchKeywords $keywordsRepo->findByLocaleForHomepage($locale);
  350.         } catch (\Throwable $e) {}
  351.         // Logger
  352.         try {
  353.             $statsRepo $this->em->getRepository(\App\Entity\Cvs\SearchStats::class);
  354.             $statsRepo->logSearch(
  355.                 $freeSearch,
  356.                 $isCityContext $keyword null,
  357.                 $locale,
  358.                 $isCityContext 'company_landing_city' 'company_landing_category'
  359.             );
  360.         } catch (\Throwable $e) {}
  361.         return $this->render('application/whileresume/application/enterprises/dashboard.html.twig', [
  362.             'initialQuery'              => $initialQuery,
  363.             'sidebarFiltersCities'      => $sidebarFiltersCities,
  364.             'sidebarFiltersCategories'  => $sidebarFiltersCategories,
  365.             'searchKeywords'            => $searchKeywords,
  366.             'currentFilter'             => $filter,
  367.             'currentCityFilter'         => $isCityContext $filter null,
  368.             'currentCityKeyword'        => $isCityContext $keyword '',
  369.             'currentCategoryFilter'     => $isCategoryContext $filter null,
  370.             'currentCategoryKeyword'    => $isCategoryContext $keyword '',
  371.             'currentSubKeyword'         => null,
  372.             'currentSubKeywordSlug'     => null,
  373.             'connectUser'               => $this->getUser() !== null,
  374.             'isCandidateUser'           => $this->isCandidateUser(),
  375.         ]);
  376.     }
  377.     /**
  378.      * Page combinée ville + keyword : /companies/{slug}/{keyword}
  379.      * Ex: /fr/companies/paris/restauration
  380.      */
  381.     public function filterLandingKeyword(Request $request$slug$keyword): Response
  382.     {
  383.         $locale $request->getLocale();
  384.         $filtersRepo $this->em->getRepository(\App\Entity\Cvs\EnterprisesFilters::class);
  385.         $filter $filtersRepo->findOneBySlugAndLocale($slug$locale);
  386.         if ($filter === null || $filter->getType() !== 'city') {
  387.             throw $this->createNotFoundException('Filtre ville introuvable');
  388.         }
  389.         $cityKeyword $filter->getSearchKeyword() ?: $filter->getLabel();
  390.         // Le keyword peut matcher un KeywordsLandingJobs ou être libre
  391.         $keywordsRepo $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class);
  392.         $searchKeywords $keywordsRepo->findByLocaleForHomepage($locale);
  393.         $matchedKeyword null;
  394.         foreach ($searchKeywords as $kw) {
  395.             if ($this->slugify($kw->getLabel()) === $keyword) {
  396.                 $matchedKeyword $kw;
  397.                 break;
  398.             }
  399.         }
  400.         $subKeywordQuery $matchedKeyword !== null
  401.             ? ($matchedKeyword->getSearchKeyword() ?: $matchedKeyword->getLabel())
  402.             : str_replace('-'' '$keyword);
  403.         // Sidebar
  404.         $sidebarFiltersCities = [];
  405.         $sidebarFiltersCategories = [];
  406.         try {
  407.             $sidebarFilters $filtersRepo->findActiveByLocale($locale30);
  408.             foreach ($sidebarFilters as $f) {
  409.                 if ($f->getType() === 'city') {
  410.                     $sidebarFiltersCities[] = $f;
  411.                 } else {
  412.                     $sidebarFiltersCategories[] = $f;
  413.                 }
  414.             }
  415.         } catch (\Throwable $e) {}
  416.         // Logger
  417.         try {
  418.             $statsRepo $this->em->getRepository(\App\Entity\Cvs\SearchStats::class);
  419.             $statsRepo->logSearch($subKeywordQuery$cityKeyword$locale'company_landing_keyword');
  420.         } catch (\Throwable $e) {}
  421.         return $this->render('application/whileresume/application/enterprises/dashboard.html.twig', [
  422.             'initialQuery'              => $subKeywordQuery,
  423.             'sidebarFiltersCities'      => $sidebarFiltersCities,
  424.             'sidebarFiltersCategories'  => $sidebarFiltersCategories,
  425.             'searchKeywords'            => $searchKeywords,
  426.             'currentFilter'             => $filter,
  427.             'currentCityFilter'         => $filter,
  428.             'currentCityKeyword'        => $cityKeyword,
  429.             'currentCategoryFilter'     => null,
  430.             'currentCategoryKeyword'    => '',
  431.             'currentSubKeyword'         => $matchedKeyword !== null $matchedKeyword->getLabel() : ucfirst(str_replace('-'' '$keyword)),
  432.             'currentSubKeywordSlug'     => $keyword,
  433.             'connectUser'               => $this->getUser() !== null,
  434.             'isCandidateUser'           => $this->isCandidateUser(),
  435.         ]);
  436.     }
  437.     /**
  438.      * API publique de recherche d'entreprises pour le dashboard.
  439.      * GET /companies/search?q=&city=&category=&locale=&offset=&limit=
  440.      */
  441.     public function apiSearch(Request $request): JsonResponse
  442.     {
  443.         $query trim((string) $request->query->get('q'''));
  444.         $locale $request->query->get('locale'$request->getLocale());
  445.         $limit max(1min(20, (int) $request->query->get('limit'10)));
  446.         $offset max(0, (int) $request->query->get('offset'0));
  447.         $city trim((string) $request->query->get('city'''));
  448.         $category trim((string) $request->query->get('category'''));
  449.         $cityKeyword $city !== '' $city null;
  450.         $categoryKeyword $category !== '' $category null;
  451.         // Si la query fait moins de 2 chars, on l'ignore (le filtre full-text sauterait sur des mots trop courts).
  452.         // Mais on retourne quand même les entreprises (toutes ou scopées par city/category si présents).
  453.         $effectiveQuery mb_strlen($query) < '' $query;
  454.         $repo $this->em->getRepository(Enterprises::class);
  455.         $enterprises $repo->searchEnterprises($effectiveQuery$locale$offset$limit$cityKeyword$categoryKeyword);
  456.         $total $repo->countSearchEnterprises($effectiveQuery$locale$cityKeyword$categoryKeyword);
  457.         // Logger uniquement à la première page ET si query meaningful (≥ 2 chars) ou un scope
  458.         if ($offset === && ($effectiveQuery !== '' || $cityKeyword !== null || $categoryKeyword !== null)) {
  459.             try {
  460.                 $statsRepo $this->em->getRepository(\App\Entity\Cvs\SearchStats::class);
  461.                 $statsRepo->logSearch($effectiveQuery$cityKeyword$locale'company_submit');
  462.             } catch (\Throwable $e) {}
  463.         }
  464.         $items = [];
  465.         foreach ($enterprises as $e) {
  466.             // Logo : utilise vich UploaderHelper (équivalent PHP de vich_uploader_asset() en Twig)
  467.             // Ça lit la config vich (mapping enterprises_logo) pour générer la bonne URL,
  468.             // au lieu du chemin en dur '/files/cvs/...' qui dépend de cette config.
  469.             $logo null;
  470.             if ($e->getImage() !== null && $e->getImage()->getName()) {
  471.                 $logo $this->uploaderHelper->asset($e'imageFile');
  472.             }
  473.             $items[] = [
  474.                 'id'          => $e->getId(),
  475.                 'slug'        => $e->getSlug(),
  476.                 'name'        => $e->getCompanyName(),
  477.                 'shortTitle'  => $e->getShortTitle(),
  478.                 'category'    => $e->getCategory(),
  479.                 'city'        => $e->getCity(),
  480.                 'country'     => $e->getCountry(),
  481.                 'logo'        => $logo,
  482.                 'firstLetter' => $e->getCompanyName() ? mb_strtoupper(mb_substr($e->getCompanyName(), 01)) : '?',
  483.                 'url'         => $this->generateUrl('locale_cvs_application_company_show', [
  484.                     '_locale' => $locale,
  485.                     'slug'    => $e->getSlug(),
  486.                 ]),
  487.             ];
  488.         }
  489.         return new JsonResponse([
  490.             'items'    => $items,
  491.             'total'    => $total,
  492.             'query'    => $query,
  493.             'city'     => $cityKeyword,
  494.             'category' => $categoryKeyword,
  495.             'limit'    => $limit,
  496.             'offset'   => $offset,
  497.         ]);
  498.     }
  499.     /**
  500.      * Slugifie une chaîne (ex: "Café & Restauration" → "cafe-and-restauration")
  501.      * IMPORTANT : doit produire EXACTEMENT le même slug que le filtre Twig
  502.      * utilisé dans dashboard.html.twig pour générer les liens des chips.
  503.      */
  504.     private function slugify(string $text): string
  505.     {
  506.         // Pré-remplacement des caractères spéciaux SÉMANTIQUES
  507.         $text strtr($text, [
  508.             '&' => '-and-',
  509.             '+' => '-plus-',
  510.             'œ' => 'oe',
  511.             'Œ' => 'oe',
  512.             'æ' => 'ae',
  513.             'Æ' => 'ae',
  514.         ]);
  515.         if (function_exists('transliterator_transliterate')) {
  516.             $text transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()'$text);
  517.         } else {
  518.             $text strtolower(iconv('UTF-8''ASCII//TRANSLIT//IGNORE'$text));
  519.         }
  520.         $text preg_replace('/[^a-z0-9]+/''-'$text);
  521.         return trim($text'-');
  522.     }
  523. }