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 $rd, EntityManagerInterface $em, UploaderHelper $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($enterprise, 5);
  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($locale, 30);
  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, $slug, int $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 $locale, string $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($locale, 30);
  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($locale, 30);
  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($locale, 30);
  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(1, min(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. $effectiveQuery = mb_strlen($query) < 2 ? '' : $query;
  452. $repo = $this->em->getRepository(Enterprises::class);
  453. $enterprises = $repo->searchEnterprises($effectiveQuery, $locale, $offset, $limit, $cityKeyword, $categoryKeyword);
  454. $total = $repo->countSearchEnterprises($effectiveQuery, $locale, $cityKeyword, $categoryKeyword);
  455. // Logger uniquement à la première page ET si query meaningful (≥ 2 chars) ou un scope
  456. if ($offset === 0 && ($effectiveQuery !== '' || $cityKeyword !== null || $categoryKeyword !== null)) {
  457. try {
  458. $statsRepo = $this->em->getRepository(\App\Entity\Cvs\SearchStats::class);
  459. $statsRepo->logSearch($effectiveQuery, $cityKeyword, $locale, 'company_submit');
  460. } catch (\Throwable $e) {}
  461. }
  462. $items = [];
  463. foreach ($enterprises as $e) {
  464. $logo = null;
  465. if ($e->getImage() !== null && $e->getImage()->getName()) {
  466. $logo = $this->uploaderHelper->asset($e, 'imageFile');
  467. }
  468. if($locale == "fr") {
  469. $items[] = [
  470. 'id' => $e->getId(),
  471. 'slug' => $e->getSlug(),
  472. 'name' => $e->getCompanyName(),
  473. 'shortTitle' => $e->getShortTitle(),
  474. 'category' => $e->getCategory(),
  475. 'city' => $e->getCity(),
  476. 'country' => $e->getCountry(),
  477. 'logo' => $logo,
  478. 'firstLetter' => $e->getCompanyName() ? mb_strtoupper(mb_substr($e->getCompanyName(), 0, 1)) : '?',
  479. 'url' => $this->generateUrl('locale_cvs_application_company_show', [
  480. '_locale' => $locale,
  481. 'slug' => $e->getSlug(),
  482. ]),
  483. ];
  484. } else {
  485. $items[] = [
  486. 'id' => $e->getId(),
  487. 'slug' => $e->getSlug(),
  488. 'name' => $e->getCompanyName(),
  489. 'shortTitle' => $e->getShortTitle(),
  490. 'category' => $e->getCategory(),
  491. 'city' => $e->getCity(),
  492. 'country' => $e->getCountry(),
  493. 'logo' => $logo,
  494. 'firstLetter' => $e->getCompanyName() ? mb_strtoupper(mb_substr($e->getCompanyName(), 0, 1)) : '?',
  495. 'url' => $this->generateUrl('cvs_application_company_show', [
  496. 'slug' => $e->getSlug(),
  497. ]),
  498. ];
  499. }
  500. }
  501. return new JsonResponse([
  502. 'items' => $items,
  503. 'total' => $total,
  504. 'query' => $query,
  505. 'city' => $cityKeyword,
  506. 'category' => $categoryKeyword,
  507. 'limit' => $limit,
  508. 'offset' => $offset,
  509. ]);
  510. }
  511. /**
  512. * Slugifie une chaîne (ex: "Café & Restauration" → "cafe-and-restauration")
  513. * IMPORTANT : doit produire EXACTEMENT le même slug que le filtre Twig
  514. * utilisé dans dashboard.html.twig pour générer les liens des chips.
  515. */
  516. private function slugify(string $text): string
  517. {
  518. // Pré-remplacement des caractères spéciaux SÉMANTIQUES
  519. $text = strtr($text, [
  520. '&' => '-and-',
  521. '+' => '-plus-',
  522. 'œ' => 'oe',
  523. 'Œ' => 'oe',
  524. 'æ' => 'ae',
  525. 'Æ' => 'ae',
  526. ]);
  527. if (function_exists('transliterator_transliterate')) {
  528. $text = transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $text);
  529. } else {
  530. $text = strtolower(iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text));
  531. }
  532. $text = preg_replace('/[^a-z0-9]+/', '-', $text);
  533. return trim($text, '-');
  534. }
  535. }