src/Controller/SellerApplicationController.php line 44

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\Category;
  4. use App\Entity\City;
  5. use App\Entity\CommandeDetails;
  6. use App\Entity\Country;
  7. use App\Entity\Magasin;
  8. use App\Entity\Produit;
  9. use App\Entity\Region;
  10. use App\Entity\SellerApplication;
  11. use App\Entity\User;
  12. use App\Service\ShopProvisioner;
  13. use Doctrine\ORM\EntityManagerInterface;
  14. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  15. use Symfony\Component\HttpFoundation\BinaryFileResponse;
  16. use Symfony\Component\HttpFoundation\Request;
  17. use Symfony\Component\HttpFoundation\Response;
  18. use Symfony\Component\HttpFoundation\ResponseHeaderBag;
  19. use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
  20. use Symfony\Component\Routing\Annotation\Route;
  21. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  22. class SellerApplicationController extends AbstractController
  23. {
  24.     // Bump this string whenever the Seller Agreement / Privacy Policy changes,
  25.     // so each acceptance record shows exactly which version the seller agreed to.
  26.     private const AGREEMENT_VERSION 'v1.0 (2026-05-31)';
  27.     // Where uploaded owner-ID files are stored — relative to the project dir.
  28.     // This is OUTSIDE public/, so the files are never served by a direct URL.
  29.     private const ID_DIR '/var/uploads/seller_ids';
  30.     // Shop logos are PUBLIC (shown as the shop icon), so they live under public/.
  31.     private const LOGO_DIR  '/public/assets/uploads/logos';
  32.     private const LOGO_SIZE 400;
  33.     public function __construct(
  34.         private EntityManagerInterface $em,
  35.         private ShopProvisioner $provisioner
  36.     ) {}
  37.     #[Route('/become-seller'name'seller_apply'methods: ['GET''POST'])]
  38.     public function apply(Request $request): Response
  39.     {
  40.         $user        $this->getUser();
  41.         $errors      = [];
  42.         $suggestions = [];
  43.         if ($request->isMethod('POST')) {
  44.             $business trim((string) $request->request->get('business_name'''));
  45.             $contact  trim((string) $request->request->get('contact_name'''));
  46.             $email    trim((string) $request->request->get('email'''));
  47.             $phone    trim((string) $request->request->get('phone'''));
  48.             $category trim((string) $request->request->get('category'''));
  49.             $country  trim((string) $request->request->get('country'''));
  50.             $region   trim((string) $request->request->get('region'''));
  51.             $city     trim((string) $request->request->get('city'''));
  52.             $lat      trim((string) $request->request->get('latitude'''));
  53.             $lng      trim((string) $request->request->get('longitude'''));
  54.             $desc     trim((string) $request->request->get('description'''));
  55.             $agree    $request->request->getBoolean('agree');
  56.             $idFile   $request->files->get('id_document');
  57.             $idExt    '';
  58.             $logoFile $request->files->get('logo');
  59.             $logoExt  '';
  60.             if ($business === '') { $errors[] = 'Business / shop name is required.'; }
  61.             if ($contact === '')  { $errors[] = 'Your name is required.'; }
  62.             if ($phone === '')    { $errors[] = 'A phone number is required.'; }
  63.             if ($email !== '' && !filter_var($emailFILTER_VALIDATE_EMAIL)) {
  64.                 $errors[] = 'Please enter a valid email address, or leave it blank.';
  65.             }
  66.             if (!$agree) {
  67.                 $errors[] = 'Please read and agree to the Seller Agreement and Privacy Policy to continue.';
  68.             }
  69.             // ── Owner ID file: required, image or PDF, max 8 MB ──
  70.             if ($idFile === null) {
  71.                 $errors[] = 'Please attach a clear photo or scan of the owner’s government-issued ID.';
  72.             } else {
  73.                 $idExt strtolower($idFile->getClientOriginalExtension() ?: ($idFile->guessExtension() ?: ''));
  74.                 if (!in_array($idExt, ['jpg''jpeg''png''webp''pdf'], true)) {
  75.                     $errors[] = 'The ID file must be a JPG, PNG, WEBP or PDF.';
  76.                 } elseif (!$idFile->isValid()) {
  77.                     $errors[] = 'The ID upload didn’t complete — please try attaching it again.';
  78.                 } elseif ($idFile->getSize() > 1024 1024) {
  79.                     $errors[] = 'The ID file is too large — please keep it under 8 MB.';
  80.                 }
  81.             }
  82.             // ── Optional shop logo: image only, max 4 MB. Only validated if provided. ──
  83.             if ($logoFile !== null) {
  84.                 $logoExt strtolower($logoFile->getClientOriginalExtension() ?: ($logoFile->guessExtension() ?: ''));
  85.                 if (!in_array($logoExt, ['jpg''jpeg''png''webp'], true)) {
  86.                     $errors[] = 'The shop logo must be a PNG, JPG or WEBP image.';
  87.                 } elseif (!$logoFile->isValid()) {
  88.                     $errors[] = 'The logo upload didn’t complete — please try attaching it again.';
  89.                 } elseif ($logoFile->getSize() > 1024 1024) {
  90.                     $errors[] = 'The logo is too large — please keep it under 4 MB.';
  91.                 }
  92.             }
  93.             if ($business !== '') {
  94.                 $takenLower $this->takenShopNames();
  95.                 if (isset($takenLower[mb_strtolower($business)])) {
  96.                     $errors[] = 'A shop named “' $business '” already exists. Please choose a different business name.';
  97.                     $suggestions $this->suggestShopNames($business$region$city$takenLower);
  98.                 }
  99.             }
  100.             if (empty($errors)) {
  101.                 // Store the ID document OUTSIDE the public web root (var/, not web-served).
  102.                 $dir $this->getParameter('kernel.project_dir') . self::ID_DIR;
  103.                 if (!is_dir($dir)) { @mkdir($dir0775true); }
  104.                 $idStored 'id_' bin2hex(random_bytes(16)) . '.' $idExt;
  105.                 try {
  106.                     $idFile->move($dir$idStored);
  107.                 } catch (\Throwable $e) {
  108.                     error_log('[Julico id upload] ' $e->getMessage());
  109.                     $errors[] = 'Could not save the ID file — please try again.';
  110.                     $idStored null;
  111.                 }
  112.                 if ($idStored !== null) {
  113.                     $appn = new SellerApplication();
  114.                     $appn->setBusinessName($business);
  115.                     $appn->setContactName($contact);
  116.                     $appn->setEmail($email);
  117.                     $appn->setPhone($phone !== '' $phone null);
  118.                     $appn->setCategory($category !== '' $category null);
  119.                     $appn->setCountry($country !== '' $country null);
  120.                     $appn->setRegion($region !== '' $region null);
  121.                     $appn->setCity($city !== '' $city null);
  122.                     $appn->setLatitude(is_numeric($lat) ? $lat null);
  123.                     $appn->setLongitude(is_numeric($lng) ? $lng null);
  124.                     $appn->setDescription($desc !== '' $desc null);
  125.                     // Only record a REAL applicant. Never stamp the application with an
  126.                     // admin / Julico-system account — that pollution is what could make
  127.                     // a later set-password link target the wrong account.
  128.                     if ($user !== null && method_exists($user'getId') && !$this->isProtectedAccount($user)) {
  129.                         $appn->setApplicantUserId($user->getId());
  130.                     }
  131.                     $appn->setIdDocument($idStored);
  132.                     // Optional logo: resize to a 400x400 square and store under public/.
  133.                     // The logo is NOT required, so a storage failure just skips it.
  134.                     if ($logoFile !== null && $logoExt !== '') {
  135.                         $logoStored $this->storeLogo($logoFile$logoExt);
  136.                         if ($logoStored !== null) {
  137.                             $appn->setLogoPath($logoStored);
  138.                         }
  139.                     }
  140.                     // Record the click-to-agree acceptance: which version, when, from where.
  141.                     $appn->setAgreedVersion(self::AGREEMENT_VERSION);
  142.                     $appn->setAgreedAt(new \DateTime());
  143.                     $appn->setAgreedIp($request->getClientIp());
  144.                     $this->em->persist($appn);
  145.                     $this->em->flush();
  146.                     return $this->redirectToRoute('seller_apply', ['ok' => 1]);
  147.                 }
  148.             }
  149.         }
  150.         $prefill = ['contact_name' => '''email' => ''];
  151.         if ($user !== null) {
  152.             $pre method_exists($user'getPrenom') ? (string) $user->getPrenom() : '';
  153.             $nom method_exists($user'getNomm') ? (string) $user->getNomm() : '';
  154.             $prefill['contact_name'] = trim($pre ' ' $nom);
  155.             if (method_exists($user'getEmail')) { $prefill['email'] = (string) $user->getEmail(); }
  156.         }
  157.         $categories = [];
  158.         try {
  159.             $categories $this->em->getRepository(Category::class)->findBy([], ['nom' => 'ASC']);
  160.         } catch (\Throwable $e) {
  161.             error_log('[Julico categories] ' $e->getMessage());
  162.         }
  163.         return $this->render('seller/apply.html.twig', [
  164.             'errors'           => $errors,
  165.             'suggestions'      => $suggestions,
  166.             'sent'             => $request->query->getBoolean('ok'),
  167.             'prefill'          => $prefill,
  168.             'old'              => $request->request->all(),
  169.             'categories'       => $categories,
  170.             'agreementVersion' => self::AGREEMENT_VERSION,
  171.         ]);
  172.     }
  173.     #[Route('/admin/seller-applications/{id}/id-document'name'admin_seller_id_document'methods: ['GET'])]
  174.     public function idDocument(int $id): Response
  175.     {
  176.         $this->denyAccessUnlessGranted('ROLE_ADMIN');
  177.         $appn $this->em->getRepository(SellerApplication::class)->find($id);
  178.         if ($appn === null || !$appn->getIdDocument()) {
  179.             throw $this->createNotFoundException();
  180.         }
  181.         $path $this->getParameter('kernel.project_dir') . self::ID_DIR '/' $appn->getIdDocument();
  182.         if (!is_file($path)) {
  183.             throw $this->createNotFoundException();
  184.         }
  185.         $ext  strtolower(pathinfo($appn->getIdDocument(), PATHINFO_EXTENSION));
  186.         $mime = match ($ext) {
  187.             'jpg''jpeg' => 'image/jpeg',
  188.             'png'         => 'image/png',
  189.             'webp'        => 'image/webp',
  190.             'pdf'         => 'application/pdf',
  191.             default       => 'application/octet-stream',
  192.         };
  193.         $response = new BinaryFileResponse($path);
  194.         $response->headers->set('Content-Type'$mime);
  195.         $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE'owner-id.' $ext);
  196.         return $response;
  197.     }
  198.     // Accounts a PUBLIC set-password link must NEVER be allowed to touch.
  199.     // Deliberately matches ONLY the exact Julico system inbox(es) — never a whole
  200.     // domain, because real sellers also use julico.io addresses (annieshopseller,
  201.     // raedshop_outsiderseller, etc). Add more system addresses to the list below
  202.     // if you ever create them. NOTE: keep the "at" sign out of /** */ doc-comments
  203.     // anywhere in this file — the route loader reads them as annotations and crashes.
  204.     private function isProtectedAccount($u): bool
  205.     {
  206.         if ($u === null || !method_exists($u'getEmail')) { return false; }
  207.         $email strtolower(trim((string) $u->getEmail()));
  208.         $systemAccounts = ['info@julico.io'];
  209.         return in_array($email$systemAccountstrue);
  210.     }
  211.     // Resize an uploaded logo to a uniform square and store under public/assets/uploads/logos/.
  212.     // Returns the stored filename, or null on failure.
  213.     private function storeLogo($filestring $ext): ?string
  214.     {
  215.         $dir $this->getParameter('kernel.project_dir') . self::LOGO_DIR;
  216.         if (!is_dir($dir)) { @mkdir($dir0775true); }
  217.         $name 'shop_' bin2hex(random_bytes(12)) . '.' $ext;
  218.         $dest $dir '/' $name;
  219.         $src  $file->getRealPath() ?: $file->getPathname();
  220.         if ($this->makeSquareLogo($src$ext$destself::LOGO_SIZE)) {
  221.             return $name;
  222.         }
  223.         try {
  224.             $file->move($dir$name);
  225.             return $name;
  226.         } catch (\Throwable $e) {
  227.             error_log('[Julico apply logo] ' $e->getMessage());
  228.             return null;
  229.         }
  230.     }
  231.     // Center cover-crop to a square of $size px. Returns true on success.
  232.     private function makeSquareLogo(string $srcstring $extstring $destint $size): bool
  233.     {
  234.         if (!function_exists('imagecreatetruecolor') || !is_file($src)) { return false; }
  235.         $img = match ($ext) {
  236.             'jpg''jpeg' => @imagecreatefromjpeg($src),
  237.             'png'         => @imagecreatefrompng($src),
  238.             'webp'        => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($src) : false,
  239.             default       => false,
  240.         };
  241.         if (!$img) { return false; }
  242.         $w imagesx($img);
  243.         $h imagesy($img);
  244.         if ($w || $h 1) { imagedestroy($img); return false; }
  245.         $side min($w$h);
  246.         $sx   = (int) (($w $side) / 2);
  247.         $sy   = (int) (($h $side) / 2);
  248.         $canvas imagecreatetruecolor($size$size);
  249.         if (in_array($ext, ['png''webp'], true)) {
  250.             imagealphablending($canvasfalse);
  251.             imagesavealpha($canvastrue);
  252.             $transparent imagecolorallocatealpha($canvas000127);
  253.             imagefilledrectangle($canvas00$size$size$transparent);
  254.         } else {
  255.             $white imagecolorallocate($canvas255255255);
  256.             imagefilledrectangle($canvas00$size$size$white);
  257.         }
  258.         imagecopyresampled($canvas$img00$sx$sy$size$size$side$side);
  259.         $ok = match ($ext) {
  260.             'jpg''jpeg' => imagejpeg($canvas$dest88),
  261.             'png'         => imagepng($canvas$dest6),
  262.             'webp'        => function_exists('imagewebp') ? imagewebp($canvas$dest88) : false,
  263.             default       => false,
  264.         };
  265.         imagedestroy($img);
  266.         imagedestroy($canvas);
  267.         return $ok === true;
  268.     }
  269.     private function takenShopNames(): array
  270.     {
  271.         $set = [];
  272.         try {
  273.             foreach ($this->em->getRepository(Magasin::class)->findAll() as $s) {
  274.                 $n method_exists($s'getNom') ? (string) $s->getNom() : '';
  275.                 if ($n !== '') { $set[mb_strtolower(trim($n))] = true; }
  276.             }
  277.         } catch (\Throwable $e) {
  278.             error_log('[Julico takenShopNames] ' $e->getMessage());
  279.         }
  280.         return $set;
  281.     }
  282.     private function shopNameMap(): array
  283.     {
  284.         $map = [];
  285.         try {
  286.             foreach ($this->em->getRepository(Magasin::class)->findAll() as $s) {
  287.                 $n method_exists($s'getNom') ? trim((string) $s->getNom()) : '';
  288.                 if ($n !== '') { $map[mb_strtolower($n)] = $n; }
  289.             }
  290.         } catch (\Throwable $e) {
  291.             error_log('[Julico shopNameMap] ' $e->getMessage());
  292.         }
  293.         return $map;
  294.     }
  295.     private function suggestShopNames(string $basestring $regionstring $city, array $takenLower): array
  296.     {
  297.         $base trim(preg_replace('/\s+/'' '$base));
  298.         if ($base === '') { return []; }
  299.         $cands = [];
  300.         foreach (['Wholesale''Trading''Co''Store''Hub''Market''Express'] as $suf) {
  301.             $cands[] = $base ' ' $suf;
  302.         }
  303.         if ($city !== '')   { $cands[] = $base ' ' $city; }
  304.         if ($region !== '') { $cands[] = $base ' ' $region; }
  305.         $cands[] = $base ' Lebanon';
  306.         $cands[] = $base ' 2';
  307.         $cands[] = $base ' 3';
  308.         $out = [];
  309.         foreach ($cands as $c) {
  310.             $c trim(preg_replace('/\s+/'' '$c));
  311.             $k mb_strtolower($c);
  312.             if ($c === '' || isset($takenLower[$k]) || isset($out[$k])) { continue; }
  313.             $out[$k] = $c;
  314.             if (count($out) >= 5) { break; }
  315.         }
  316.         return array_values($out);
  317.     }
  318.     private function nameChecks(array $apps): array
  319.     {
  320.         $byLower    $this->shopNameMap();
  321.         $takenLower = [];
  322.         foreach ($byLower as $k => $v) { $takenLower[$k] = true; }
  323.         $out = [];
  324.         foreach ($apps as $a) {
  325.             $name trim((string) $a->getBusinessName());
  326.             if ($name === '') { continue; }
  327.             $lk mb_strtolower($name);
  328.             if (isset($byLower[$lk])) {
  329.                 $out[$a->getId()] = [
  330.                     'taken'       => true,
  331.                     'conflict'    => $byLower[$lk],
  332.                     'suggestions' => $this->suggestShopNames($name, (string) $a->getRegion(), (string) $a->getCity(), $takenLower),
  333.                 ];
  334.             } else {
  335.                 $out[$a->getId()] = ['taken' => false'conflict' => null'suggestions' => []];
  336.             }
  337.         }
  338.         return $out;
  339.     }
  340.     private function waNumber(?string $phone): string
  341.     {
  342.         $d preg_replace('/\D+/''', (string) $phone);
  343.         if ($d === '') { return ''; }
  344.         if (str_starts_with($d'00')) { $d substr($d2); }
  345.         elseif (str_starts_with($d'0')) { $d substr($d1); }
  346.         return $d;
  347.     }
  348.     private function entName($e): string
  349.     {
  350.         if ($e === null) { return ''; }
  351.         foreach (['getName''getNom''getLibelle''getTitle'] as $m) {
  352.             if (method_exists($e$m)) {
  353.                 try { $v $e->$m(); } catch (\Throwable $ex) { $v null; }
  354.                 if ($v !== null && $v !== '') { return (string) $v; }
  355.             }
  356.         }
  357.         return '';
  358.     }
  359.     private function locNorm(?string $s): string
  360.     {
  361.         $s mb_strtolower(trim((string) $s));
  362.         $s preg_replace('/\b(governorate|mohafazat|muhafazah|district|caza|qada|province|region|city of|el|al)\b/u'' '$s);
  363.         $s preg_replace('/[^a-z\s]/u'' '$s);
  364.         $s preg_replace('/\s+/'' '$s);
  365.         return trim($s);
  366.     }
  367.     private function locMatch(string $astring $b): bool
  368.     {
  369.         if ($a === '' || $b === '') { return false; }
  370.         if ($a === $b) { return true; }
  371.         return str_contains($a$b) || str_contains($b$a);
  372.     }
  373.     private function resolveLocationIds(SellerApplication $a): array
  374.     {
  375.         $out = ['countryId' => null'regionId' => null'cityId' => null];
  376.         try {
  377.             $cTxt $this->locNorm($a->getCountry());
  378.             $rTxt $this->locNorm($a->getRegion());
  379.             $tTxt $this->locNorm($a->getCity());
  380.             $country null;
  381.             if ($cTxt !== '') {
  382.                 foreach ($this->em->getRepository(Country::class)->findAll() as $c) {
  383.                     if ($this->locNorm($c->getName()) === $cTxt) { $country $c; break; }
  384.                 }
  385.                 if ($country === null) {
  386.                     foreach ($this->em->getRepository(Country::class)->findAll() as $c) {
  387.                         if ($this->locMatch($this->locNorm($c->getName()), $cTxt)) { $country $c; break; }
  388.                     }
  389.                 }
  390.             }
  391.             $region null;
  392.             if ($rTxt !== '') {
  393.                 $regions $country !== null
  394.                     $country->getRegions()->toArray()
  395.                     : $this->em->getRepository(Region::class)->findAll();
  396.                 foreach ($regions as $r) {
  397.                     if ($this->locNorm($r->getName()) === $rTxt) { $region $r; break; }
  398.                 }
  399.                 if ($region === null) {
  400.                     foreach ($regions as $r) {
  401.                         if ($this->locMatch($this->locNorm($r->getName()), $rTxt)) { $region $r; break; }
  402.                     }
  403.                 }
  404.             }
  405.             if ($region !== null && $country === null) { $country $region->getCountry(); }
  406.             $city null;
  407.             if ($tTxt !== '' && $region !== null) {
  408.                 foreach ($region->getCities() as $ci) {
  409.                     if ($this->locNorm($ci->getName()) === $tTxt) { $city $ci; break; }
  410.                 }
  411.                 if ($city === null) {
  412.                     foreach ($region->getCities() as $ci) {
  413.                         if ($this->locMatch($this->locNorm($ci->getName()), $tTxt)) { $city $ci; break; }
  414.                     }
  415.                 }
  416.             }
  417.             $out['countryId'] = $country?->getId();
  418.             $out['regionId']  = $region?->getId();
  419.             $out['cityId']    = $city?->getId();
  420.         } catch (\Throwable $e) {
  421.             error_log('[Julico resolveLocationIds] ' $e->getMessage());
  422.         }
  423.         return $out;
  424.     }
  425.     private function productPrice($p): float
  426.     {
  427.         try {
  428.             if (method_exists($p'getSalePrice')) { $sp $p->getSalePrice(); if ($sp !== null && (float) $sp 0) { return (float) $sp; } }
  429.             if (method_exists($p'getPrix')) { return (float) ($p->getPrix() ?? 0); }
  430.         } catch (\Throwable $e) {}
  431.         return 0.0;
  432.     }
  433.     private function shopArea($shop): array
  434.     {
  435.         return [
  436.             'city'    => method_exists($shop'getCity')    ? $this->entName($shop->getCity())    : '',
  437.             'region'  => method_exists($shop'getRegion')  ? $this->entName($shop->getRegion())  : '',
  438.             'country' => method_exists($shop'getCountry') ? $this->entName($shop->getCountry()) : '',
  439.         ];
  440.     }
  441.     private function competition(array $apps): array
  442.     {
  443.         $hasPending false;
  444.         foreach ($apps as $a) { if ($a->getStatus() === 'pending') { $hasPending true; break; } }
  445.         if (!$hasPending) { return []; }
  446.         $out = [];
  447.         try {
  448.             $rows $this->em->createQueryBuilder()
  449.                 ->select('p''c''m''mc''mr''mci')
  450.                 ->from(Produit::class, 'p')
  451.                 ->join('p.category''c')
  452.                 ->join('p.magasin''m')
  453.                 ->leftJoin('m.country''mc')
  454.                 ->leftJoin('m.region''mr')
  455.                 ->leftJoin('m.city''mci')
  456.                 ->getQuery()->getResult();
  457.             $catalogue = [];
  458.             foreach ($rows as $p) {
  459.                 $shop method_exists($p'getMagasin')  ? $p->getMagasin()  : null;
  460.                 $cat  method_exists($p'getCategory') ? $p->getCategory() : null;
  461.                 if ($shop === null || $cat === null) { continue; }
  462.                 $catNom method_exists($cat'getNom') ? (string) $cat->getNom() : '';
  463.                 $area   $this->shopArea($shop);
  464.                 $catalogue[] = [
  465.                     'product'   => method_exists($p'getProductName') ? (string) $p->getProductName() : '',
  466.                     'price'     => $this->productPrice($p),
  467.                     'catLower'  => mb_strtolower(trim($catNom)),
  468.                     'shopId'    => method_exists($shop'getId')  ? (int) $shop->getId()  : 0,
  469.                     'shopNom'   => method_exists($shop'getNom') ? (string) $shop->getNom() : '',
  470.                     'cityLower' => mb_strtolower($area['city']),
  471.                     'regLower'  => mb_strtolower($area['region']),
  472.                     'areaLabel' => trim($area['city'] !== '' $area['city'] : $area['region']),
  473.                 ];
  474.             }
  475.             foreach ($apps as $a) {
  476.                 if ($a->getStatus() !== 'pending') { continue; }
  477.                 $appCat  mb_strtolower(trim((string) $a->getCategory()));
  478.                 $appCity mb_strtolower(trim((string) $a->getCity()));
  479.                 $appReg  mb_strtolower(trim((string) $a->getRegion()));
  480.                 $catLabel  trim((string) $a->getCategory());
  481.                 $areaLabel trim((string) $a->getCity()) !== '' trim((string) $a->getCity()) : trim((string) $a->getRegion());
  482.                 $areaShops = []; $catShops = []; $overlapShops = [];
  483.                 $items = []; $overlapItemCount 0;
  484.                 foreach ($catalogue as $row) {
  485.                     $areaMatch = ($appCity !== '' && $row['cityLower'] === $appCity)
  486.                               || ($appReg  !== '' && $row['regLower']  === $appReg);
  487.                     $catMatch  $appCat !== '' && (
  488.                                     $row['catLower'] === $appCat
  489.                                  || ($row['catLower'] !== '' && (str_contains($row['catLower'], $appCat) || str_contains($appCat$row['catLower'])))
  490.                               );
  491.                     if ($areaMatch) { $areaShops[$row['shopId']] = true; }
  492.                     if ($catMatch)  { $catShops[$row['shopId']]  = true; }
  493.                     if ($catMatch && $areaMatch) {
  494.                         $overlapShops[$row['shopId']] = true;
  495.                         $overlapItemCount++;
  496.                         if (count($items) < 12) {
  497.                             $items[] = [
  498.                                 'product' => $row['product'],
  499.                                 'shop'    => $row['shopNom'],
  500.                                 'area'    => $row['areaLabel'],
  501.                                 'price'   => $row['price'],
  502.                             ];
  503.                         }
  504.                     }
  505.                 }
  506.                 $nArea count($areaShops);
  507.                 $nCat  count($catShops);
  508.                 $nOver count($overlapShops);
  509.                 if ($overlapItemCount 0)      { $severity 'high'; }
  510.                 elseif ($nCat || $nArea 0) { $severity 'medium'; }
  511.                 else                             { $severity 'low'; }
  512.                 $parts = [];
  513.                 if ($catLabel !== '')  { $parts[] = 'Sells “' $catLabel '”'; }
  514.                 if ($areaLabel !== '') { $parts[] = 'in ' $areaLabel; }
  515.                 $lead $parts implode(' '$parts) . '. ' '';
  516.                 if ($severity === 'high') {
  517.                     $summary $lead $nOver ' shop' . ($nOver !== 's' '') . ' here already sell' . ($nOver === 's' '')
  518.                              . ' this category — ' $overlapItemCount ' overlapping item' . ($overlapItemCount !== 's' '') . '.';
  519.                 } elseif ($severity === 'medium') {
  520.                     $bits = [];
  521.                     if ($nCat 0)  { $bits[] = $nCat ' shop' . ($nCat !== 's' '') . ' sell' . ($nCat === 's' '') . ' this category (elsewhere)'; }
  522.                     if ($nArea 0) { $bits[] = $nArea ' shop' . ($nArea !== 's' '') . ' in this area (other categories)'; }
  523.                     $summary $lead implode('; '$bits) . '.';
  524.                 } else {
  525.                     $summary $lead 'No existing shop overlaps on category or area.';
  526.                 }
  527.                 $out[$a->getId()] = [
  528.                     'severity'  => $severity,
  529.                     'summary'   => $summary,
  530.                     'items'     => $items,
  531.                     'moreItems' => max(0$overlapItemCount count($items)),
  532.                 ];
  533.             }
  534.         } catch (\Throwable $e) {
  535.             error_log('[Julico competition] ' $e->getMessage());
  536.             return [];
  537.         }
  538.         return $out;
  539.     }
  540.     #[Route('/admin/seller-applications'name'admin_seller_applications'methods: ['GET'])]
  541.     public function list(Request $request): Response
  542.     {
  543.         $this->denyAccessUnlessGranted('ROLE_ADMIN');
  544.         $status = (string) $request->query->get('status''pending');
  545.         $repo   $this->em->getRepository(SellerApplication::class);
  546.         $criteria in_array($status, ['pending''approved''rejected'], true) ? ['status' => $status] : [];
  547.         $apps $repo->findBy($criteria, ['created_at' => 'DESC']);
  548.         $shopsByApp = [];
  549.         $waPhone    = [];
  550.         $setupUrl   = [];
  551.         $now = new \DateTime();
  552.         foreach ($apps as $a) {
  553.             $waPhone[$a->getId()] = $this->waNumber($a->getPhone());
  554.             $mid $a->getCreatedMagasinId();
  555.             if ($mid) {
  556.                 $s $this->em->getRepository(Magasin::class)->find($mid);
  557.                 if ($s) { $shopsByApp[$a->getId()] = $s; }
  558.             }
  559.             $tok $a->getSetupToken();
  560.             if ($tok && $a->getSetupTokenExpires() && $a->getSetupTokenExpires() >= $now) {
  561.                 $setupUrl[$a->getId()] = $this->generateUrl('seller_setup', ['token' => $tok], UrlGeneratorInterface::ABSOLUTE_URL);
  562.             }
  563.         }
  564.         return $this->render('super_admin/seller_applications.html.twig', [
  565.             'apps'        => $apps,
  566.             'status'      => $status,
  567.             'history'     => $this->sellerHistory($apps),
  568.             'nameChecks'  => $this->nameChecks($apps),
  569.             'competition' => $this->competition($apps),
  570.             'shopsByApp'  => $shopsByApp,
  571.             'waPhone'     => $waPhone,
  572.             'setupUrl'    => $setupUrl,
  573.             'counts'  => [
  574.                 'pending'  => $repo->count(['status' => 'pending']),
  575.                 'approved' => $repo->count(['status' => 'approved']),
  576.                 'rejected' => $repo->count(['status' => 'rejected']),
  577.                 'all'      => $repo->count([]),
  578.             ],
  579.         ]);
  580.     }
  581.     #[Route('/admin/seller-applications/{id}/decide'name'admin_seller_decide'methods: ['POST'])]
  582.     public function decide(int $idRequest $request): Response
  583.     {
  584.         $this->denyAccessUnlessGranted('ROLE_ADMIN');
  585.         $appn $this->em->getRepository(SellerApplication::class)->find($id);
  586.         if ($appn === null) { throw $this->createNotFoundException(); }
  587.         $action = (string) $request->request->get('action''');
  588.         $note   trim((string) $request->request->get('admin_note'''));
  589.         $status $request->query->get('status''pending');
  590.         if ($action === 'approve') {
  591.             $managerEmail trim((string) $request->request->get('manager_email'''));
  592.             if ($managerEmail === '') {
  593.                 $managerEmail trim((string) $appn->getEmail());
  594.             }
  595.             if ($managerEmail === '' || !filter_var($managerEmailFILTER_VALIDATE_EMAIL)) {
  596.                 $this->addFlash('error''Enter a valid login email to approve — this is the account the shop is linked to.');
  597.                 return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
  598.             }
  599.             if (trim((string) $appn->getEmail()) !== $managerEmail) {
  600.                 $appn->setEmail($managerEmail);
  601.             }
  602.             // Carry the applicant's pinned location over to the new shop.
  603.             $loc $this->resolveLocationIds($appn);
  604.             $res $this->provisioner->provision([
  605.                 'shopName'     => $appn->getBusinessName(),
  606.                 'active'       => false,
  607.                 'countryId'    => $loc['countryId'],
  608.                 'regionId'     => $loc['regionId'],
  609.                 'cityId'       => $loc['cityId'],
  610.                 'managerEmail' => $managerEmail,
  611.                 'managerName'  => $appn->getContactName(),
  612.                 'managerPhone' => $appn->getPhone(),
  613.                 'tempPassword' => $request->request->get('temp_password'),
  614.             ], $this->getUser());
  615.             if ($res['error'] !== null) {
  616.                 $this->addFlash('error''Could not approve: ' $res['error']);
  617.                 return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
  618.             }
  619.             $appn->setStatus('approved');
  620.             $appn->setCreatedMagasinId($res['shop']->getId());
  621.             // Carry the logo the seller uploaded at apply time onto the new shop.
  622.             if ($appn->getLogoPath()) {
  623.                 $res['shop']->setLogoPath($appn->getLogoPath());
  624.             }
  625.             // The shop is linked to $res['manager'] — that account, and ONLY that
  626.             // account, is what the set-password link may target. Always point the
  627.             // application at it, overwriting any stale applicant id captured from
  628.             // whoever happened to be logged in when the form was submitted.
  629.             if ($res['manager'] !== null) {
  630.                 $appn->setApplicantUserId($res['manager']->getId());
  631.             }
  632.             if ($res['createdUser']) {
  633.                 $appn->setSetupToken(bin2hex(random_bytes(24)));
  634.                 $appn->setSetupTokenExpires((new \DateTime())->modify('+30 day'));
  635.                 $acct ' A new account was created — send the seller their set-password link (WhatsApp button below).';
  636.             } else {
  637.                 $appn->setSetupToken(null);
  638.                 $appn->setSetupTokenExpires(null);
  639.                 $acct ' Linked to existing account ' $res['manager']->getEmail() . ' (password unchanged).';
  640.             }
  641.             $this->addFlash('success''Approved — shop “' $res['shop']->getNom() . '” created (awaiting payment, not on the storefront yet). Confirm a payment below to make it live.' $acct);
  642.         } elseif ($action === 'reject') {
  643.             $appn->setStatus('rejected');
  644.             $this->addFlash('success''Application rejected.');
  645.         }
  646.         $appn->setAdminNote($note !== '' $note null);
  647.         $appn->setReviewedAt(new \DateTime());
  648.         $this->em->flush();
  649.         return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
  650.     }
  651.     #[Route('/admin/seller-applications/{id}/confirm-payment'name'admin_seller_confirm_payment'methods: ['POST'])]
  652.     public function confirmPayment(int $idRequest $request): Response
  653.     {
  654.         $this->denyAccessUnlessGranted('ROLE_ADMIN');
  655.         $appn $this->em->getRepository(SellerApplication::class)->find($id);
  656.         if ($appn === null) { throw $this->createNotFoundException(); }
  657.         $status $request->query->get('status''approved');
  658.         $shopId $appn->getCreatedMagasinId();
  659.         $shop   $shopId $this->em->getRepository(Magasin::class)->find($shopId) : null;
  660.         if ($shop === null) {
  661.             $this->addFlash('error''No shop is linked to this application yet — approve it first.');
  662.             return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
  663.         }
  664.         $months = (int) $request->request->get('months'0);
  665.         if (!in_array($months, [13612], true)) {
  666.             $this->addFlash('error''Pick a valid subscription length (1, 3, 6 or 12 months).');
  667.             return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
  668.         }
  669.         $today = new \DateTime('today');
  670.         $base  $shop->getSubscriptionPaidUntil();
  671.         $start = ($base !== null && $base $today) ? (clone $base) : (clone $today);
  672.         $newUntil $start->modify('+' $months ' month');
  673.         $shop->setSubscriptionPaidUntil($newUntil);
  674.         $shop->setActive(true);
  675.         $this->em->flush();
  676.         $this->addFlash('success''Payment confirmed — “' $shop->getNom() . '” is now live until ' $newUntil->format('M j, Y') . '.');
  677.         return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
  678.     }
  679.     #[Route('/admin/seller-applications/{id}/remove-subscription'name'admin_seller_remove_subscription'methods: ['POST'])]
  680.     public function removeSubscription(int $idRequest $request): Response
  681.     {
  682.         $this->denyAccessUnlessGranted('ROLE_ADMIN');
  683.         $appn $this->em->getRepository(SellerApplication::class)->find($id);
  684.         if ($appn === null) { throw $this->createNotFoundException(); }
  685.         $status $request->query->get('status''approved');
  686.         $shopId $appn->getCreatedMagasinId();
  687.         $shop   $shopId $this->em->getRepository(Magasin::class)->find($shopId) : null;
  688.         if ($shop === null) {
  689.             $this->addFlash('error''No shop is linked to this application.');
  690.             return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
  691.         }
  692.         $shop->setActive(false);
  693.         $shop->setSubscriptionPaidUntil(null);
  694.         $this->em->flush();
  695.         $this->addFlash('success''Subscription removed — “' $shop->getNom() . '” is now off the storefront (awaiting payment). Confirm a payment to reactivate it.');
  696.         return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
  697.     }
  698.     #[Route('/seller-setup/{token}'name'seller_setup'methods: ['GET''POST'])]
  699.     public function setupPassword(string $tokenRequest $requestUserPasswordHasherInterface $hasher): Response
  700.     {
  701.         $appn $this->em->getRepository(SellerApplication::class)->findOneBy(['setup_token' => $token]);
  702.         $valid $appn !== null
  703.             && $appn->getSetupTokenExpires() !== null
  704.             && $appn->getSetupTokenExpires() >= new \DateTime();
  705.         if (!$valid) {
  706.             return $this->render('seller/setup_password.html.twig', ['invalid' => true]);
  707.         }
  708.         $errors = [];
  709.         if ($request->isMethod('POST')) {
  710.             if (!$this->isCsrfTokenValid('seller_setup', (string) $request->request->get('_token'))) {
  711.                 $errors[] = 'Security check failed — please try again.';
  712.             }
  713.             $p1 = (string) $request->request->get('password''');
  714.             $p2 = (string) $request->request->get('password_confirm''');
  715.             if (strlen($p1) < 8)  { $errors[] = 'Password must be at least 8 characters.'; }
  716.             if ($p1 !== $p2)      { $errors[] = 'The two passwords don’t match.'; }
  717.             if (empty($errors)) {
  718.                 // Resolve the account this link is allowed to set a password for.
  719.                 // PRIMARY source of truth = the manager email the shop was linked to
  720.                 // at approval time. The stored applicant id is only a FALLBACK, because
  721.                 // it can be polluted by whoever happened to be logged in at apply time.
  722.                 $user  null;
  723.                 $email strtolower(trim((string) $appn->getEmail()));
  724.                 if ($email !== '') {
  725.                     $user $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
  726.                 }
  727.                 if ($user === null) {
  728.                     $uid  $appn->getApplicantUserId();
  729.                     $user $uid $this->em->getRepository(User::class)->find($uid) : null;
  730.                 }
  731.                 if ($user === null) {
  732.                     $errors[] = 'Account not found — please contact Julico support.';
  733.                 } elseif ($this->isProtectedAccount($user)) {
  734.                     // Hard stop: a public set-password link must NEVER change the Julico
  735.                     // system inbox. Refuse, change nothing.
  736.                     error_log('[Julico setupPassword] refused protected account "'
  737.                         . (method_exists($user'getEmail') ? $user->getEmail() : '?')
  738.                         . '" via token for application #' $appn->getId());
  739.                     $errors[] = 'This setup link is misconfigured and was blocked for safety — no password was changed. Please contact Julico support.';
  740.                 } else {
  741.                     $user->setPassword($hasher->hashPassword($user$p1));
  742.                     $appn->setSetupToken(null);
  743.                     $appn->setSetupTokenExpires(null);
  744.                     $this->em->flush();
  745.                     $this->addFlash('success''Your password is set — you can now log in.');
  746.                     return $this->redirect('/login');
  747.                 }
  748.             }
  749.         }
  750.         return $this->render('seller/setup_password.html.twig', [
  751.             'invalid' => false,
  752.             'errors'  => $errors,
  753.             'token'   => $token,
  754.             'email'   => $appn->getEmail(),
  755.             'shop'    => $appn->getBusinessName(),
  756.         ]);
  757.     }
  758.     private function sellerHistory(array $apps): array
  759.     {
  760.         try {
  761.             $userRepo  $this->em->getRepository(User::class);
  762.             $appShops  = [];
  763.             $allShopIds = [];
  764.             foreach ($apps as $a) {
  765.                 $email strtolower(trim((string) $a->getEmail()));
  766.                 if ($email === '') { continue; }
  767.                 $u $userRepo->findOneBy(['email' => $email]);
  768.                 if ($u === null || !method_exists($u'getLinkedMagasins')) { continue; }
  769.                 foreach ($u->getLinkedMagasins() as $shop) {
  770.                     if ($shop === null || !method_exists($shop'getId')) { continue; }
  771.                     $sid = (int) $shop->getId();
  772.                     if ($a->getCreatedMagasinId() !== null && (int) $a->getCreatedMagasinId() === $sid) { continue; }
  773.                     $appShops[$a->getId()][$sid] = [
  774.                         'name'   => method_exists($shop'getNom') ? (string) $shop->getNom() : ('#' $sid),
  775.                         'active' => method_exists($shop'isActive') ? ($shop->isActive() === true) : false,
  776.                     ];
  777.                     $allShopIds[$sid] = true;
  778.                 }
  779.             }
  780.             if (empty($allShopIds)) { return []; }
  781.             $ids array_keys($allShopIds);
  782.             $prodCounts = [];
  783.             $rows $this->em->createQueryBuilder()
  784.                 ->select('m.id AS sid''COUNT(p.id) AS cnt')
  785.                 ->from(Produit::class, 'p')
  786.                 ->join('p.magasin''m')
  787.                 ->where('m.id IN (:ids)')->setParameter('ids'$ids)
  788.                 ->groupBy('m.id')->getQuery()->getArrayResult();
  789.             foreach ($rows as $r) { $prodCounts[(int) $r['sid']] = (int) $r['cnt']; }
  790.             $agg   = [];
  791.             $lines $this->em->createQueryBuilder()
  792.                 ->select('d''c''p''m''v')
  793.                 ->from(CommandeDetails::class, 'd')
  794.                 ->join('d.commande''c')
  795.                 ->join('d.produit''p')
  796.                 ->join('p.magasin''m')
  797.                 ->leftJoin('d.produitVariant''v')
  798.                 ->where('m.id IN (:ids)')->setParameter('ids'$ids)
  799.                 ->getQuery()->getResult();
  800.             foreach ($lines as $d) {
  801.                 $c method_exists($d'getCommande') ? $d->getCommande() : null;
  802.                 $p method_exists($d'getProduit') ? $d->getProduit() : null;
  803.                 if ($c === null || $p === null) { continue; }
  804.                 $shop method_exists($p'getMagasin') ? $p->getMagasin() : null;
  805.                 $sid  = ($shop && method_exists($shop'getId')) ? (int) $shop->getId() : null;
  806.                 if ($sid === null || !isset($allShopIds[$sid])) { continue; }
  807.                 if ($this->lineCancelled($c)) { continue; }
  808.                 if (!isset($agg[$sid])) { $agg[$sid] = ['orders' => [], 'units' => 0'revenue' => 0.0]; }
  809.                 $qty  = (int) (method_exists($d'getProduitQttCmd') ? ($d->getProduitQttCmd() ?? 0) : 0);
  810.                 $unit $this->lineUnitPrice($d$p);
  811.                 $agg[$sid]['units']   += $qty;
  812.                 $agg[$sid]['revenue'] += $qty $unit;
  813.                 if (method_exists($c'getId')) { $agg[$sid]['orders'][(int) $c->getId()] = true; }
  814.             }
  815.             $out = [];
  816.             foreach ($appShops as $appId => $shops) {
  817.                 $listShops = []; $tOrders 0$tUnits 0$tRev 0.0;
  818.                 foreach ($shops as $sid => $meta) {
  819.                     $a      $agg[$sid] ?? ['orders' => [], 'units' => 0'revenue' => 0.0];
  820.                     $orders count($a['orders']);
  821.                     $listShops[] = [
  822.                         'name'     => $meta['name'],
  823.                         'active'   => $meta['active'],
  824.                         'products' => $prodCounts[$sid] ?? 0,
  825.                         'orders'   => $orders,
  826.                         'units'    => $a['units'],
  827.                         'revenue'  => round($a['revenue'], 2),
  828.                     ];
  829.                     $tOrders += $orders$tUnits += $a['units']; $tRev += $a['revenue'];
  830.                 }
  831.                 usort($listShops, fn($x$y) => strcasecmp($x['name'], $y['name']));
  832.                 $out[$appId] = [
  833.                     'shops'        => $listShops,
  834.                     'totalOrders'  => $tOrders,
  835.                     'totalUnits'   => $tUnits,
  836.                     'totalRevenue' => round($tRev2),
  837.                 ];
  838.             }
  839.             return $out;
  840.         } catch (\Throwable $e) {
  841.             error_log('[Julico sellerHistory] ' $e->getMessage());
  842.             return [];
  843.         }
  844.     }
  845.     private function lineCancelled($c): bool
  846.     {
  847.         try {
  848.             $label '';
  849.             if (method_exists($c'getCmdStatus')) {
  850.                 $cs $c->getCmdStatus();
  851.                 if ($cs !== null && method_exists($cs'getStatusDesc')) { $label = (string) $cs->getStatusDesc(); }
  852.             }
  853.             if ($label === '' && method_exists($c'getStatusCmd')) { $label = (string) $c->getStatusCmd(); }
  854.             return str_contains(strtolower($label), 'cancel');
  855.         } catch (\Throwable $e) { return false; }
  856.     }
  857.     private function lineUnitPrice($d$p): float
  858.     {
  859.         try {
  860.             $v method_exists($d'getProduitVariant') ? $d->getProduitVariant() : null;
  861.             if ($v !== null && method_exists($v'getPrix')) {
  862.                 $vp $v->getPrix();
  863.                 if ($vp !== null && (float) $vp 0) { return (float) $vp; }
  864.             }
  865.             if (method_exists($p'getSalePrice')) {
  866.                 $sp $p->getSalePrice();
  867.                 if ($sp !== null && (float) $sp 0) { return (float) $sp; }
  868.             }
  869.             if (method_exists($p'getPrix')) { return (float) ($p->getPrix() ?? 0); }
  870.         } catch (\Throwable $e) {}
  871.         return 0.0;
  872.     }
  873. }