src/Controller/ThemesWebsite/Whileresume/Website/CandidatesController.php line 509

Open in your IDE?
  1. <?php
  2. namespace App\Controller\ThemesWebsite\Whileresume\Website;
  3. use App\Entity\Articles\Articles;
  4. use App\Entity\Core\Users;
  5. use App\Entity\Cvs\Candidates;
  6. use App\Entity\Cvs\Jobs;
  7. use App\Entity\Cvs\JobsHasLikes;
  8. use App\Entity\Cvs\KeywordsLandingJobs;
  9. use App\Entity\Cvs\LandingJobs;
  10. use App\Entity\Pages\Pages;
  11. use App\Form\Core\UsersEmailForm;
  12. use App\Security\LoginFormAuthenticator;
  13. use App\Services\Core\RequestData;
  14. use Doctrine\ORM\EntityManagerInterface;
  15. use Knp\Component\Pager\PaginatorInterface;
  16. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  17. use Symfony\Component\HttpFoundation\JsonResponse;
  18. use Symfony\Component\HttpFoundation\Request;
  19. use Symfony\Component\HttpFoundation\Response;
  20. use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
  21. use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
  22. use Twig\Environment;
  23. class CandidatesController extends AbstractController
  24. {
  25. private $rd;
  26. private $em;
  27. private $passwordEncoder;
  28. private $ms;
  29. private $us;
  30. private $authenticator;
  31. private $userAuthenticator;
  32. private $paginator;
  33. private $twig;
  34. public function __construct(RequestData $rd,
  35. EntityManagerInterface $em,
  36. UserPasswordEncoderInterface $passwordEncoder,
  37. \App\Services\Mails $ms,
  38. \App\Services\Core\Users $us,
  39. UserAuthenticatorInterface $userAuthenticator,
  40. LoginFormAuthenticator $authenticator,
  41. PaginatorInterface $paginator,
  42. Environment $twig,
  43. ) {
  44. $this->rd = $rd;
  45. $this->em = $em;
  46. $this->passwordEncoder = $passwordEncoder;
  47. $this->ms = $ms;
  48. $this->authenticator = $authenticator;
  49. $this->userAuthenticator = $userAuthenticator;
  50. $this->us = $us;
  51. $this->paginator = $paginator;
  52. $this->twig = $twig;
  53. }
  54. /**
  55. * Homepage principal
  56. * @param Request $request
  57. * @return Response
  58. */
  59. public function homepage(Request $request): Response
  60. {
  61. $locale = $request->getLocale();
  62. // Page meta (title, description, robots, etc.)
  63. $page = $this->em->getRepository(Pages::class)->findOneBy(['name' => 'homepage', 'locale' => $locale]);
  64. // Articles : 6 derniers pour la section "Conseils carrière"
  65. $articles = $this->em->getRepository(Articles::class)->getArticles(6, $locale);
  66. // Stat hero : nombre total d'offres en ligne
  67. $totalJobs = $this->em->getRepository(Jobs::class)->countAllOnlineJobs();
  68. // Jobs aléatoires affichés par défaut dans le hero (filtrés par locale de navigation)
  69. // On régénère le seed à chaque chargement de la homepage : ainsi l'utilisateur
  70. // voit des offres différentes à chaque visite. Le seed est ensuite stocké en
  71. // session pour que la pagination AJAX (searchJobsApi sans query) garde le
  72. // même ordre que celui rendu côté serveur.
  73. $seed = random_int(1, PHP_INT_MAX);
  74. $request->getSession()->set('jobs_random_seed', $seed);
  75. $randomJobs = $this->em->getRepository(Jobs::class)->findRandomJobs($locale, 0, 6, $seed);
  76. // Mots-clés populaires (categories) — affichés en pills + sections d'offres associées
  77. $keywordsEntities = $this->em->getRepository(KeywordsLandingJobs::class)->findByLocaleForHomepage($locale);
  78. $popularKeywords = [];
  79. $popularJobs = [];
  80. $jobsRepo = $this->em->getRepository(Jobs::class);
  81. foreach ($keywordsEntities as $kw) {
  82. $label = $kw->getLabel() ?? $kw->getSearchKeyword();
  83. $icon = $kw->getIcon() ?? '';
  84. $keyword = $kw->getSearchKeyword() ?? $kw->getLabel();
  85. // Liste utilisée pour la barre de pills "Recherchez par catégorie"
  86. $popularKeywords[] = [
  87. 'label' => $label,
  88. 'icon' => $icon,
  89. 'keyword' => $keyword,
  90. ];
  91. // Pour chaque keyword, on ramène 4 offres pour la section "Offres populaires"
  92. $popularJobs[] = [
  93. 'label' => $label,
  94. 'icon' => $icon,
  95. 'jobs' => $jobsRepo->findByKeyword($keyword, $locale, 4),
  96. ];
  97. }
  98. // Form d'inscription pour la section signup intégrée à la homepage.
  99. // Le form POST vers la page inscription qui contient déjà toute la logique
  100. // (validation, reCAPTCHA, envoi mail de bienvenue, auth automatique).
  101. $form = $this->createForm(UsersEmailForm::class, null, [
  102. 'action' => $this->generateUrl('whileresume_resume_' . $locale),
  103. 'method' => 'POST',
  104. ]);
  105. // Incrémenter compteur de vues
  106. if ($page !== null) {
  107. $page->setViews((int)$page->getViews() + 1);
  108. $this->em->persist($page);
  109. $this->em->flush();
  110. }
  111. return $this->render('application/whileresume/website/candidates/homepage.html.twig', [
  112. 'page' => $page,
  113. 'articles' => $articles,
  114. 'totalJobs' => $totalJobs,
  115. 'randomJobs' => $randomJobs,
  116. 'popularKeywords' => $popularKeywords,
  117. 'popularJobs' => $popularJobs,
  118. 'form' => $form->createView(),
  119. ]);
  120. }
  121. private function jobsKeyword($keyword)
  122. {
  123. return $this->em->getRepository(Jobs::class)->searchJobs($keyword,'en',0,10);
  124. }
  125. /**
  126. * API : Recherche d'offres (AJAX)
  127. *
  128. * Si aucun query n'est fourni : on retourne des offres aléatoires
  129. * toutes langues confondues, et le total est le nombre total d'offres
  130. * en ligne sur tout le site.
  131. *
  132. * Si un query est fourni : recherche classique filtrée par locale.
  133. */
  134. public function searchJobsApi(Request $request): JsonResponse
  135. {
  136. $locale = $request->getLocale();
  137. $query = trim($request->query->get('q', ''));
  138. $offset = max(0, (int) $request->query->get('offset', 0));
  139. $limit = min(20, max(1, (int) $request->query->get('limit', 10)));
  140. $jobsRepo = $this->em->getRepository(Jobs::class);
  141. if ($query === '') {
  142. // Mode "default" : jobs aléatoires filtrés par locale de navigation.
  143. // On utilise un seed basé sur la session pour conserver le même ordre
  144. // aléatoire entre les paginations successives.
  145. $session = $request->getSession();
  146. $seed = $session->get('jobs_random_seed');
  147. if ($seed === null) {
  148. $seed = random_int(1, PHP_INT_MAX);
  149. $session->set('jobs_random_seed', $seed);
  150. }
  151. $jobs = $jobsRepo->findRandomJobs($locale, $offset, $limit, $seed);
  152. // Compteur "toutes langues confondues" pour rester cohérent avec
  153. // l'affichage initial côté Twig (totalJobs = countAllOnlineJobs).
  154. $total = $jobsRepo->countAllOnlineJobs();
  155. } else {
  156. // Recherche classique : on reset le seed pour la prochaine fois
  157. // qu'on revient en mode default
  158. $request->getSession()->remove('jobs_random_seed');
  159. $jobs = $jobsRepo->searchJobs($query, $locale, $offset, $limit);
  160. $total = $jobsRepo->countSearchJobs($query, $locale);
  161. }
  162. // Récupérer les likes du candidat connecté
  163. $likedJobIds = [];
  164. $user = $this->getUser();
  165. if ($user !== null && method_exists($user, 'getCandidate') && $user->getCandidate() !== null) {
  166. $likes = $this->em->getRepository(JobsHasLikes::class)->findBy(['candidate' => $user->getCandidate()]);
  167. foreach ($likes as $like) {
  168. if ($like->getJob() !== null) {
  169. $likedJobIds[] = $like->getJob()->getId();
  170. }
  171. }
  172. }
  173. $results = [];
  174. foreach ($jobs as $job) {
  175. $results[] = $this->serializeJob($job, $likedJobIds);
  176. }
  177. return new JsonResponse([
  178. 'jobs' => $results,
  179. 'total' => $total,
  180. 'offset' => $offset,
  181. 'limit' => $limit,
  182. 'hasMore' => ($offset + $limit) < $total,
  183. ]);
  184. }
  185. /**
  186. * API : Like / Unlike une offre (AJAX)
  187. */
  188. public function toggleJobLikeApi(Request $request): JsonResponse
  189. {
  190. $user = $this->getUser();
  191. if ($user === null) {
  192. return new JsonResponse(['error' => 'Vous devez être connecté.'], 401);
  193. }
  194. $candidate = $user->getCandidate();
  195. if ($candidate === null) {
  196. return new JsonResponse(['error' => 'Vous devez être candidat pour liker une offre.'], 403);
  197. }
  198. $data = json_decode($request->getContent(), true);
  199. $jobId = $data['jobId'] ?? null;
  200. if ($jobId === null) {
  201. return new JsonResponse(['error' => 'ID de l\'offre manquant.'], 400);
  202. }
  203. $job = $this->em->getRepository(Jobs::class)->find($jobId);
  204. if ($job === null) {
  205. return new JsonResponse(['error' => 'Offre introuvable.'], 404);
  206. }
  207. // Vérifier si le like existe déjà
  208. $existingLike = $this->em->getRepository(JobsHasLikes::class)->findOneBy([
  209. 'job' => $job,
  210. 'candidate' => $candidate,
  211. ]);
  212. if ($existingLike !== null) {
  213. // Unlike
  214. $this->em->remove($existingLike);
  215. $this->em->flush();
  216. return new JsonResponse(['liked' => false, 'jobId' => $jobId]);
  217. }
  218. // Like
  219. $like = new JobsHasLikes();
  220. $like->setJob($job);
  221. $like->setCandidate($candidate);
  222. $this->em->persist($like);
  223. $this->em->flush();
  224. return new JsonResponse(['liked' => true, 'jobId' => $jobId]);
  225. }
  226. // ======================================================================
  227. // HELPERS
  228. // ======================================================================
  229. /**
  230. * Sérialise un Job en tableau pour la réponse JSON
  231. */
  232. private function serializeJob(Jobs $job, array $likedJobIds): array
  233. {
  234. $now = new \DateTime();
  235. $diffDays = $job->getCreatedAt() ? $now->diff($job->getCreatedAt())->days : 999;
  236. // Image du job (Vich uploader) — adapter le chemin si nécessaire
  237. $imageUrl = null;
  238. if ($job->getImage() && $job->getImage()->getName()) {
  239. $imageUrl = '/uploads/cv_files/' . $job->getImage()->getName();
  240. }
  241. return [
  242. 'id' => $job->getId(),
  243. 'jobTitle' => $job->getJobTitle(),
  244. 'companyName' => $job->getCompanyName() ?? 'Entreprise',
  245. 'city' => $job->getCity() ?? '',
  246. 'employmentType' => $job->getEmploymentType() ?? 'CDI',
  247. 'experienceLevel' => $job->getExperienceLevel() ?? '',
  248. 'remoteWork' => $job->getRemoteWork() ?? '',
  249. 'salary' => $this->formatSalary($job->getSalaryMin(), $job->getSalaryMax(), $job->getDevise()),
  250. 'slug' => $job->getSlug(),
  251. 'isNew' => $diffDays <= 7,
  252. 'isLiked' => in_array($job->getId(), $likedJobIds),
  253. 'image' => $imageUrl,
  254. 'logo' => $job->getCompanyName() ? mb_strtoupper(mb_substr($job->getCompanyName(), 0, 1)) : '?',
  255. 'category' => $job->getCategory() ?? '',
  256. ];
  257. }
  258. /**
  259. * Formate le salaire en string lisible : "55-70k€"
  260. */
  261. private function formatSalary(?int $min, ?int $max, ?string $devise): string
  262. {
  263. $symbol = ($devise === 'USD' || $devise === '$') ? '$' : '€';
  264. if ($min && $max) {
  265. return floor($min / 1000) . '-' . floor($max / 1000) . 'k' . $symbol;
  266. }
  267. if ($min) {
  268. return floor($min / 1000) . 'k' . $symbol . '+';
  269. }
  270. if ($max) {
  271. return '< ' . floor($max / 1000) . 'k' . $symbol;
  272. }
  273. return '';
  274. }
  275. // ======================================================================
  276. // MÉTHODES EXISTANTES (inchangées)
  277. // ======================================================================
  278. public function homepageLanding(Request $request, $slug): Response
  279. {
  280. $locale = $request->getLocale();
  281. $user = $this->getUser();
  282. $landing = $this->em->getRepository(LandingJobs::class)->findOneBy(['type' => 'candidates', 'slug' => $slug, 'locale' => $locale]);
  283. if($landing === null) {
  284. throw new \RuntimeException('No exist');
  285. }
  286. $articles = $this->em->getRepository(Articles::class)->getArticles(6,$locale);
  287. // Nombre total d'offres en ligne sur tout le site
  288. $totalJobs = $this->em->getRepository(Jobs::class)->countAllOnlineJobs();
  289. // Données pour utilisateur connecté
  290. $likedJobIds = [];
  291. if ($user !== null) {
  292. if (method_exists($user, 'getCandidate') && $user->getCandidate() !== null) {
  293. $likes = $this->em->getRepository(JobsHasLikes::class)->findBy(['candidate' => $user->getCandidate()]);
  294. foreach ($likes as $like) {
  295. if ($like->getJob() !== null) {
  296. $likedJobIds[] = $like->getJob()->getId();
  297. }
  298. }
  299. }
  300. }
  301. // Charger les keywords populaires associés à cette landing page
  302. $keywordsEntities = $this->em->getRepository(KeywordsLandingJobs::class)->findByLanding($landing);
  303. $popularKeywords = [];
  304. foreach ($keywordsEntities as $kw) {
  305. $popularKeywords[] = [
  306. 'label' => $kw->getLabel() ?? $kw->getSearchKeyword(),
  307. 'icon' => $kw->getIcon() ?? '',
  308. 'keyword' => $kw->getSearchKeyword() ?? $kw->getLabel(),
  309. 'searchKeyword' => $kw->getSearchKeyword() ?? $kw->getLabel(),
  310. ];
  311. }
  312. // Offres populaires par keyword (pour l'animation visiteur, comme la homepage)
  313. $jobsRepo = $this->em->getRepository(Jobs::class);
  314. $popularJobsForVisitor = [];
  315. foreach ($popularKeywords as $pk) {
  316. $jobs = $jobsRepo->findByKeyword($pk['keyword'], $locale, 4);
  317. $serializedJobs = [];
  318. foreach ($jobs as $job) {
  319. $now = new \DateTime();
  320. $diffDays = $job->getCreatedAt() ? $now->diff($job->getCreatedAt())->days : 999;
  321. $daysUntilExpiry = 30 - $diffDays;
  322. $serializedJobs[] = [
  323. 'jobTitle' => $job->getJobTitle() ?? 'Poste',
  324. 'city' => $job->getCity() ?? 'Non précisé',
  325. 'employmentType' => $job->getEmploymentType() ?? 'CDI',
  326. 'createdAt' => $job->getCreatedAt() ? $job->getCreatedAt()->format('d/m/Y') : '',
  327. 'diffDays' => $diffDays,
  328. 'daysUntilExpiry' => $daysUntilExpiry,
  329. 'slug' => $job->getSlug(),
  330. ];
  331. }
  332. $popularJobsForVisitor[] = [
  333. 'text' => $pk['label'],
  334. 'icon' => $pk['icon'],
  335. 'searchKeyword' => $pk['keyword'],
  336. 'jobs' => $serializedJobs,
  337. ];
  338. }
  339. return $this->render('application/whileresume/website/candidates/landing.html.twig',[
  340. 'landing' => $landing,
  341. 'articles' => $articles,
  342. 'totalJobs' => $totalJobs,
  343. 'popularKeywords' => $popularKeywords,
  344. 'likedJobIds' => $likedJobIds,
  345. 'popularJobsForVisitor' => $popularJobsForVisitor,
  346. ]);
  347. }
  348. /**
  349. * Déposer un CV
  350. * @param Request $request
  351. * @return Response
  352. */
  353. public function resume(Request $request): Response
  354. {
  355. $session = $request->getSession();
  356. $locale = $request->getLocale();
  357. $user = $this->getUser();
  358. $errorMessage = "";
  359. $page = $this->em->getRepository(Pages::class)->findOneBy(['name' => 'resume', 'locale' => $locale]);
  360. $form = $this->createForm(UsersEmailForm::class);
  361. $form->handleRequest($request);
  362. if ($form->isSubmitted() && $form->isValid()) {
  363. $data = $request->request->all();
  364. $data = $data['users_email_form'];
  365. if($data['acceptTerm'] == "1") {
  366. if($data['password']['first'] != $data['password']['second']) {
  367. if($locale == "fr") {
  368. $session->getFlashBag()->add('danger', 'Votre second mot de passe n\'est pas identique');
  369. return $this->redirectToRoute('whileresume_resume_fr');
  370. }
  371. $session->getFlashBag()->add('danger', 'Your second password is not identical');
  372. return $this->redirectToRoute('whileresume_resume_en');
  373. }
  374. $verificationUser = $this->em->getRepository(Users::class)->findOneBy(['email' => $data['email']]);
  375. if ($verificationUser == null) {
  376. $candidate = new Candidates();
  377. $candidate->setEmail($data['email']);
  378. $candidate->setUpdatedAt(new \DateTime("now"));
  379. $candidate->setCreatedAt(new \DateTime("now"));
  380. $candidate->setOnline(false);
  381. $candidate->setAvailability("offline");
  382. $this->em->persist($candidate);
  383. $this->em->flush();
  384. $us1 = $this->generateUniqueSlug($candidate->getId(),10);
  385. $us2 = $this->generateUniqueSlug($candidate->getId(),10);
  386. $candidate->setSlug($us1);
  387. $candidate->setSlugAnonyme($us2);
  388. $this->em->persist($candidate);
  389. $this->em->flush();
  390. $newUser = new Users();
  391. $newUser->setEmail($data['email']);
  392. $newUser->setUsername("");
  393. $newUser->setVerification(false);
  394. $newUser->setNotificationsMessages(true);
  395. $newUser->setNotificationsSuivis(true);
  396. $newUser->setPremium(false);
  397. $newUser->setFirst(true);
  398. $newUser->setEnabled(true);
  399. $newUser->setPassword($this->passwordEncoder->encodePassword($newUser,$data['password']['first']));
  400. $newUser->setRoles(['ROLE_USER']);
  401. $newUser->setTypeAccount("candidate");
  402. $newUser->setUpdatedAt(new \DateTime("now"));
  403. $newUser->setCreatedAt(new \DateTime("now"));
  404. $newUser->setCandidate($candidate);
  405. $this->em->persist($newUser);
  406. $this->em->flush();
  407. $title = "Find your next tech talents in just a few minutes!";
  408. if($locale == "fr") {
  409. $title = "🎉 Bienvenue ! Votre profil peut déjà attirer des recruteurs !";
  410. }
  411. $descriptionHTML = $this->twig->render("application/whileresume/gestion/emails/". $locale ."/register_candidate.html.twig",[
  412. 'title' => $title,
  413. 'email' => $data['email']
  414. ]);
  415. $this->ms->webhook($title,$descriptionHTML, null, $data['email'], null, null);
  416. $this->userAuthenticator->authenticateUser($newUser, $this->authenticator, $request);
  417. if($locale == "en") {
  418. return $this->redirectToRoute('customer_homepage');
  419. }
  420. return $this->redirectToRoute('locale_customer_homepage',['_locale' => $locale]);
  421. }
  422. if($locale == "fr") {
  423. $session->getFlashBag()->add('danger', $errorMessage);
  424. return $this->redirectToRoute('whileresume_resume_fr');
  425. }
  426. $errorMessage = "You are already registered with this email";
  427. $session->getFlashBag()->add('danger', $errorMessage);
  428. return $this->redirectToRoute('whileresume_resume_en');
  429. }
  430. }
  431. return $this->render('application/whileresume/website/candidates/resume.html.twig',[
  432. 'form' => $form->createView(),
  433. 'page' => $page
  434. ]);
  435. }
  436. private function generateUniqueSlug(int $userId, int $length = 8): string
  437. {
  438. $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
  439. $slug = '';
  440. for ($i = 0; $i < $length; $i++) {
  441. $slug .= $characters[random_int(0, strlen($characters) - 1)];
  442. }
  443. return $userId . '-' . $slug;
  444. }
  445. }