<?php
namespace App\Controller;
use App\Entity\Shop;
use App\Entity\Product;
use App\Entity\Category;
use App\Entity\ShopPlan;
use App\Entity\OrderItem;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use App\Entity\User;
use App\Form\SellerShopType;
use App\Repository\ShopPlanRepository;
use App\Service\NotificationService;
use App\Service\ShopLimitService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mime\Address as EmailAddress;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\String\Slugger\AsciiSlugger;
use function Symfony\Component\String\s;
#[Route('/seller_space', name: 'seller_')]
class SellerController extends AbstractController
{
#[Route('/', name: 'index')]
public function indexSeller(EntityManagerInterface $em): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
$user = $this->getUser();
if (!$user instanceof User) {
return $this->redirectToRoute('ui_app_login');
}
// Récupérer toutes les boutiques de l'utilisateur (relation ManyToMany)
$shops = $em->getRepository(Shop::class)
->createQueryBuilder('s')
->where(':user MEMBER OF s.manager')
->setParameter('user', $user)
->getQuery()
->getResult();
$shopsCount = count($shops);
// Initialiser les statistiques
$stats = [
'shopsCount' => $shopsCount,
'totalProducts' => 0,
'activeProducts' => 0,
'totalOrders' => 0,
'totalRevenue' => 0.0,
'averageCart' => 0.0,
'conversionRate' => 0.0,
'totalFollowers' => 0,
'totalViews' => 0,
'recentOrders' => [],
'topShops' => []
];
if ($shopsCount > 0) {
// Calculer le nombre total de produits
$totalProducts = $em->getRepository(Product::class)
->createQueryBuilder('p')
->select('COUNT(p.id)')
->where('p.shop IN (:shops)')
->setParameter('shops', $shops)
->getQuery()
->getSingleScalarResult();
$stats['totalProducts'] = (int)$totalProducts;
// Calculer le nombre de produits actifs
$activeProducts = $em->getRepository(Product::class)
->createQueryBuilder('p')
->select('COUNT(p.id)')
->where('p.shop IN (:shops)')
->andWhere('p.isActive = :active')
->setParameter('shops', $shops)
->setParameter('active', true)
->getQuery()
->getSingleScalarResult();
$stats['activeProducts'] = (int)$activeProducts;
// Calculer le nombre total de commandes
try {
$totalOrders = $em->createQueryBuilder()
->select('COUNT(DISTINCT o.id)')
->from(\App\Entity\Order::class, 'o')
->join('o.items', 'oi')
->join('oi.product', 'p')
->where('p.shop IN (:shops)')
->setParameter('shops', $shops)
->getQuery()
->getSingleScalarResult();
$stats['totalOrders'] = (int)$totalOrders;
// Calculer le chiffre d'affaires total
$totalRevenue = $em->createQueryBuilder()
->select('COALESCE(SUM(CAST(oi.totalPrice AS DECIMAL(10,2))), 0)')
->from(OrderItem::class, 'oi')
->join('oi.product', 'p')
->join('oi.order', 'o')
->where('p.shop IN (:shops)')
->andWhere('o.paymentStatus = :paid')
->setParameter('shops', $shops)
->setParameter('paid', 'paid')
->getQuery()
->getSingleScalarResult();
$stats['totalRevenue'] = (float)$totalRevenue;
// Calculer le panier moyen
if ($stats['totalOrders'] > 0) {
$stats['averageCart'] = $stats['totalRevenue'] / $stats['totalOrders'];
}
// Récupérer les commandes récentes (10 dernières)
$recentOrders = $em->getRepository(\App\Entity\Order::class)
->createQueryBuilder('o')
->select('DISTINCT o')
->leftJoin('o.items', 'oi')
->leftJoin('oi.product', 'p')
->leftJoin('o.customer', 'c')
->addSelect('oi', 'p', 'c')
->where('p.shop IN (:shops)')
->setParameter('shops', $shops)
->orderBy('o.orderedAt', 'DESC')
->setMaxResults(10)
->getQuery()
->getResult();
$stats['recentOrders'] = $recentOrders;
} catch (\Exception $e) {
// Si l'entité Order n'existe pas encore, on continue avec des valeurs par défaut
}
// Calculer le total des followers (somme des followers de toutes les boutiques)
foreach ($shops as $shop) {
$stats['totalFollowers'] += $shop->getFollowerCount() ?? 0;
$stats['totalViews'] += $shop->getViewCount() ?? 0;
}
// Calculer le taux de conversion (approximation basée sur les vues et commandes)
if ($stats['totalViews'] > 0) {
$stats['conversionRate'] = ($stats['totalOrders'] / $stats['totalViews']) * 100;
}
// Calculer les top boutiques par revenus
$topShops = [];
foreach ($shops as $shop) {
try {
$shopRevenue = $em->createQueryBuilder()
->select('COALESCE(SUM(CAST(oi.totalPrice AS DECIMAL(10,2))), 0)')
->from(OrderItem::class, 'oi')
->join('oi.product', 'p')
->join('oi.order', 'o')
->where('p.shop = :shop')
->andWhere('o.paymentStatus = :paid')
->setParameter('shop', $shop)
->setParameter('paid', 'paid')
->getQuery()
->getSingleScalarResult();
$topShops[] = [
'shop' => $shop,
'revenue' => (float)$shopRevenue
];
} catch (\Exception $e) {
$topShops[] = [
'shop' => $shop,
'revenue' => 0.0
];
}
}
// Trier par revenus décroissants
usort($topShops, function($a, $b) {
return $b['revenue'] <=> $a['revenue'];
});
$stats['topShops'] = array_slice($topShops, 0, 5); // Top 5
}
return $this->render('seller/index.html.twig', [
'current_menu' => 'home',
'stats' => $stats,
'shops' => $shops
]);
}
#[Route('/help/how-to-sell', name: 'help_how_to_sell')]
public function howToSell(): Response
{
return $this->render('seller/help/how_to_sell.html.twig', [
'current_menu' => 'help'
]);
}
#[Route('/help/pricing', name: 'help_pricing')]
public function pricing(EntityManagerInterface $em): Response
{
$shopPlans = $em->getRepository(ShopPlan::class)->findAll();
return $this->render('seller/help/pricing.html.twig', [
'current_menu' => 'help',
'shopPlans' => $shopPlans
]);
}
#[Route('/help/support', name: 'help_support')]
public function support(): Response
{
return $this->render('seller/help/support.html.twig', [
'current_menu' => 'help'
]);
}
#[Route('/shop/new', name: 'shop_new')]
public function newShopSeller(Request $request, EntityManagerInterface $entityManager, NotificationService $notificationService, MailerInterface $mailer, ShopLimitService $shopLimitService): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
// Récupérer l'utilisateur et vérifier la limite dès l'accès à la page
$authenticatedUser = $this->getUser();
if (!$authenticatedUser instanceof User) {
return $this->redirectToRoute('ui_app_login');
}
$userId = $authenticatedUser->getId();
$user = $entityManager->getRepository(User::class)->find($userId);
if (!$user) {
throw $this->createNotFoundException('Utilisateur introuvable');
}
// Vérifier la limite de boutiques par utilisateur AVANT d'afficher le formulaire
$shopLimitCheck = $shopLimitService->canUserCreateShop($user);
if (!$shopLimitCheck['allowed']) {
// Afficher un message d'erreur visible au lieu de rediriger
$this->addFlash('error', $shopLimitCheck['message']);
// Récupérer tous les plans pour le template (même si la limite est atteinte)
$shopPlans = $entityManager->getRepository(ShopPlan::class)->findAll();
$plansData = [];
foreach ($shopPlans as $plan) {
$plansData[$plan->getId()] = [
'id' => $plan->getId(),
'name' => $plan->getName(),
'requireSiretNif' => $plan->isRequireSiretNif(),
'requireIban' => $plan->isRequireIban(),
'maxProducts' => $plan->getMaxProducts(),
'maxEmployees' => $plan->getMaxEmployees(),
];
}
$shop = new Shop();
$form = $this->createForm(SellerShopType::class, $shop);
return $this->render('seller/new_shop_simple.html.twig', [
'current_menu' => 'shop',
'current' => 'shopNew',
'shop' => $shop,
'form' => $form->createView(),
'plansData' => $plansData,
'shopLimitCheck' => $shopLimitCheck // Passer les infos de limite au template
]);
}
$shop = new Shop();
$form = $this->createForm(SellerShopType::class, $shop);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Vérifier à nouveau la limite lors de la soumission (au cas où elle aurait changé)
$shopLimitCheck = $shopLimitService->canUserCreateShop($user);
if (!$shopLimitCheck['allowed']) {
$this->addFlash('error', $shopLimitCheck['message']);
// Récupérer tous les plans pour les contraintes JavaScript
$shopPlans = $entityManager->getRepository(ShopPlan::class)->findAll();
$plansData = [];
foreach ($shopPlans as $plan) {
$plansData[$plan->getId()] = [
'id' => $plan->getId(),
'name' => $plan->getName(),
'requireSiretNif' => $plan->isRequireSiretNif(),
'requireIban' => $plan->isRequireIban(),
'maxProducts' => $plan->getMaxProducts(),
'maxEmployees' => $plan->getMaxEmployees(),
];
}
return $this->render('seller/new_shop_simple.html.twig', [
'current_menu' => 'shop',
'current' => 'shopNew',
'shop' => $shop,
'form' => $form->createView(),
'plansData' => $plansData,
'shopLimitCheck' => $shopLimitCheck
]);
}
// Configuration de base
$shop->setIsActive(true);
$shop->setCreatedAt(new \DateTimeImmutable('now'));
$shop->setCurrentEmployees(1);
// Génération automatique du slug si nécessaire
if ($shop->getName()) {
$slugger = new AsciiSlugger();
$shop->setSlug(strtolower($slugger->slug($shop->getName())->toString()));
}
// Initialisation des compteurs
$shop->setCurrentOrders(0);
$shop->setCurrentProducts(0);
$shop->setCurrentCaLimit(0);
$shop->setCurrentRevenue('0.000');
$shop->setCurrentStorage(0);
// Initialisation des statistiques
$shop->setViewCount(0);
$shop->setFollowerCount(0);
$shop->setAverageRating(0.0);
$shop->setReviewCount(0);
// Configuration des statuts par défaut
if (!$shop->getStatus()) {
$shop->setStatus('pending');
}
if (!$shop->getVerificationStatus()) {
$shop->setVerificationStatus('unverified');
}
// Configuration des options par défaut (uniquement pour les champs modifiables par le vendeur)
if ($shop->isIsActive() === null) {
$shop->setIsActive(true);
}
if ($shop->isAllowReviews() === null) {
$shop->setAllowReviews(true);
}
if ($shop->isAllowMessages() === null) {
$shop->setAllowMessages(true);
}
if ($shop->isShowContactInfo() === null) {
$shop->setShowContactInfo(true);
}
if ($shop->isShowSocialLinks() === null) {
$shop->setShowSocialLinks(true);
}
// Champs gérés uniquement par l'administrateur (ne pas modifier)
// isVerified, isPremium, status, verificationStatus sont gérés par l'admin
if (!$shop->getStatus()) {
$shop->setStatus('pending');
}
if (!$shop->getVerificationStatus()) {
$shop->setVerificationStatus('unverified');
}
if ($shop->isIsVerified() === null) {
$shop->setIsVerified(false);
}
if ($shop->isIsPremium() === null) {
$shop->setIsPremium(false);
}
// Gestion des fichiers uploadés
$logoFile = $form->get('logo')->getData();
if ($logoFile && $logoFile->isValid()) {
$maxSize = 15 * 1024 * 1024; // 15MB
if ($logoFile->getSize() > $maxSize) {
$this->addFlash('error', 'Le logo est trop volumineux. Taille maximale : 15MB.');
} else {
$newFilename = uniqid().'.'.$logoFile->guessExtension();
$logoFile->move(
$this->getParameter('kernel.project_dir').'/public/uploads/shops/',
$newFilename
);
$shop->setLogo('uploads/shops/'.$newFilename);
}
}
// Gestion des images de bannière (jusqu'à 7 images, 15MB max chacune)
$allBannerImages = $shop->getAllBannerImages();
// Gestion des nouvelles images multiples de bannière
$bannerImagesFile = $form->get('bannerImages')->getData();
if ($bannerImagesFile) {
$maxSize = 15 * 1024 * 1024; // 15MB
foreach ($bannerImagesFile as $file) {
if ($file && $file->isValid()) {
// Vérifier la taille du fichier
if ($file->getSize() > $maxSize) {
$this->addFlash('error', 'Le fichier "' . $file->getClientOriginalName() . '" est trop volumineux. Taille maximale : 15MB.');
continue;
}
$newFilename = uniqid().'.'.$file->guessExtension();
$file->move(
$this->getParameter('kernel.project_dir').'/public/uploads/shops/',
$newFilename
);
$allBannerImages[] = 'uploads/shops/'.$newFilename;
}
}
}
// Dédupliquer et limiter à 7 images max
$allBannerImages = array_values(array_unique($allBannerImages));
if (count($allBannerImages) > 7) {
$this->addFlash('error', 'Maximum 7 images de bannière autorisées. Les images supplémentaires ont été ignorées.');
$allBannerImages = array_slice($allBannerImages, 0, 7);
}
$shop->setBannerImages($allBannerImages);
// Contraintes basées sur le plan sélectionné
if ($shop->getPlan()) {
$plan = $shop->getPlan();
$errors = [];
// Vérifier les champs requis par le plan
if ($plan->isRequireSiretNif() && !$shop->getSiretNif()) {
$errors[] = 'Votre plan exige un SIRET/NIF. Veuillez le renseigner.';
}
if ($plan->isRequireIban() && !$shop->getIban()) {
$errors[] = 'Votre plan exige un IBAN. Veuillez le renseigner.';
}
// Si des erreurs, retourner au formulaire
if (!empty($errors)) {
foreach ($errors as $error) {
$this->addFlash('error', $error);
}
return $this->render('seller/new_shop_simple.html.twig', [
'current_menu' => 'shop',
'current' => 'shopNew',
'shop' => $shop,
'form' => $form->createView()
]);
}
}
$shop->addManager($user);
$entityManager->persist($shop);
$entityManager->flush();
// Créer une notification de félicitations
$notificationService->createShopCreatedNotification($user, $shop);
// Envoyer un email de félicitations
try {
$email = (new TemplatedEmail())
->from(new EmailAddress('no-reply@maketou-ht.com', 'MaketOu'))
->to(new EmailAddress($user->getEmail(), $user->getFirstname() . ' ' . $user->getLastname()))
->subject('Félicitations ! Votre boutique "' . $shop->getName() . '" a été créée')
->htmlTemplate('emails/shop_created.html.twig')
->context([
'user' => $user,
'shop' => $shop,
]);
$mailer->send($email);
} catch (\Exception $e) {
// Log l'erreur mais ne bloque pas la création de la boutique
// On peut logger l'erreur ici si nécessaire
}
$this->addFlash('success', 'Boutique créée avec succès ! Vous avez reçu un email de confirmation.');
return $this->redirectToRoute('seller_index', [], Response::HTTP_SEE_OTHER);
}
// Récupérer tous les plans pour les contraintes JavaScript
$shopPlans = $entityManager->getRepository(ShopPlan::class)->findAll();
$plansData = [];
foreach ($shopPlans as $plan) {
$plansData[$plan->getId()] = [
'id' => $plan->getId(),
'name' => $plan->getName(),
'requireSiretNif' => $plan->isRequireSiretNif(),
'requireIban' => $plan->isRequireIban(),
'maxProducts' => $plan->getMaxProducts(),
'maxEmployees' => $plan->getMaxEmployees(),
];
}
return $this->render('seller/new_shop_simple.html.twig', [
'current_menu' => 'shop',
'current' => 'shopNew',
'shop' => $shop,
'form' => $form->createView(),
'plansData' => $plansData,
'shopLimitCheck' => $shopLimitCheck // Passer les infos de limite au template
]);
}
#[Route('/shop/{slug}/show', name: 'shop_show')]
public function showShopSeller(string $slug, Request $request, EntityManagerInterface $em): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
// Récupérer la boutique
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $slug]);
if (!$shop) {
$this->addFlash('error', 'Boutique non trouvée. Le slug "' . $slug . '" ne correspond à aucune boutique.');
return $this->redirectToRoute('seller_index');
}
// Vérifier que la boutique appartient à l'utilisateur connecté
$canEdit = $shop->getManager()->contains($this->getUser());
// Calculer les statistiques dynamiques
$productsCount = $shop->getActiveProductsCount();
// Optimisation: Utiliser une requête COUNT au lieu de charger la collection
$totalProducts = $em->getRepository(Product::class)
->createQueryBuilder('p')
->select('COUNT(p.id)')
->where('p.shop = :shop')
->setParameter('shop', $shop)
->getQuery()
->getSingleScalarResult();
// Optimisation: Utiliser des requêtes séparées optimisées au lieu de charger toutes les commandes
$recentOrders = [];
$ordersCount = 0;
$revenue = 0.0;
try {
$orderRepo = $em->getRepository(\App\Entity\Order::class);
// Compter les commandes avec une requête COUNT optimisée
$ordersCount = $orderRepo->createQueryBuilder('o')
->select('COUNT(DISTINCT o.id)')
->join('o.items', 'oi')
->join('oi.product', 'p')
->where('p.shop = :shop')
->setParameter('shop', $shop)
->getQuery()
->getSingleScalarResult();
// Calculer le chiffre d'affaires directement avec SUM (évite de charger toutes les commandes)
$revenueResult = $em->createQueryBuilder()
->select('COALESCE(SUM(CAST(oi.totalPrice AS DECIMAL(10,2))), 0)')
->from(\App\Entity\OrderItem::class, 'oi')
->join('oi.product', 'p')
->join('oi.order', 'o')
->where('p.shop = :shop')
->andWhere('o.paymentStatus = :paid')
->setParameter('shop', $shop)
->setParameter('paid', 'paid')
->getQuery()
->getSingleScalarResult();
$revenue = (float)$revenueResult;
// Récupérer seulement les 10 commandes récentes avec eager loading
$recentOrders = $orderRepo->createQueryBuilder('o')
->select('DISTINCT o')
->leftJoin('o.items', 'oi')
->leftJoin('oi.product', 'p')
->leftJoin('o.customer', 'c')
->addSelect('oi', 'p', 'c') // Eager loading pour éviter N+1
->where('p.shop = :shop')
->setParameter('shop', $shop)
->orderBy('o.orderedAt', 'DESC')
->setMaxResults(10)
->getQuery()
->getResult();
} catch (\Exception $e) {
// Si l'entité Order n'existe pas encore ou erreur, on continue sans
$ordersCount = 0;
$revenue = 0.0;
$recentOrders = [];
}
// Calculer le solde disponible (revenue - dépenses potentielles)
// Pour l'instant, on utilise le revenue comme solde disponible
$availableBalance = $revenue;
// Calculer la date de dernière mise à jour (date de création ou dernière commande)
$lastUpdate = $shop->getCreatedAt();
if ($ordersCount > 0 && !empty($recentOrders)) {
// Utiliser la date de la commande la plus récente
$lastUpdate = $recentOrders[0]->getOrderedAt();
}
// Calculer le temps depuis la dernière mise à jour
$updateText = 'Aujourd\'hui';
if ($lastUpdate) {
$now = new \DateTimeImmutable();
$diff = $now->diff($lastUpdate);
if ($diff->days > 0) {
if ($diff->days == 1) {
$updateText = 'Hier';
} elseif ($diff->days < 7) {
$updateText = 'Il y a ' . $diff->days . ' jours';
} elseif ($diff->days < 30) {
$weeks = floor($diff->days / 7);
$updateText = 'Il y a ' . $weeks . ' semaine' . ($weeks > 1 ? 's' : '');
} else {
$months = floor($diff->days / 30);
$updateText = 'Il y a ' . $months . ' mois';
}
}
}
// Calculer les statistiques de vues par période (derniers 30 jours)
$viewsLast30Days = 0;
try {
// Pour l'instant, on utilise viewCount comme approximation
$viewsLast30Days = (int)($shop->getViewCount() * 0.3); // Approximation
} catch (\Exception $e) {
// Ignorer si erreur
}
// Récupérer les produits populaires (actifs uniquement, triés par ventes puis vues)
$popularProducts = [];
try {
$productRepo = $em->getRepository(\App\Entity\Product::class);
$popularProducts = $productRepo->createQueryBuilder('p')
->where('p.shop = :shop')
->andWhere('p.isActive = :isActive')
->setParameter('shop', $shop)
->setParameter('isActive', true)
->orderBy('p.salesCount', 'DESC')
->addOrderBy('p.viewCount', 'DESC')
->addOrderBy('p.publishedAt', 'DESC')
->setMaxResults(6)
->getQuery()
->getResult();
} catch (\Exception $e) {
// En cas d'erreur, utiliser les produits actifs de la boutique
$popularProducts = $shop->getProducts()->filter(function($product) {
return $product->isIsActive();
})->toArray();
// Trier par ventes puis vues
usort($popularProducts, function($a, $b) {
$salesDiff = ($b->getSalesCount() ?? 0) - ($a->getSalesCount() ?? 0);
if ($salesDiff !== 0) return $salesDiff;
return ($b->getViewCount() ?? 0) - ($a->getViewCount() ?? 0);
});
$popularProducts = array_slice($popularProducts, 0, 6);
}
return $this->render('seller/show_shop.html.twig', [
'current_menu' => 'shop',
'slugShop' => $shop->getSlug(),
'shop' => $shop,
'canEdit' => $canEdit,
'recentOrders' => $recentOrders,
'viewsLast30Days' => $viewsLast30Days,
'popularProducts' => $popularProducts,
'stats' => [
'productsCount' => $productsCount,
'totalProducts' => $totalProducts,
'ordersCount' => $ordersCount,
'revenue' => $revenue,
'availableBalance' => $availableBalance,
'lastUpdate' => $updateText
]
]);
}
#[Route('/shop/{slug}/edit', name: 'shop_edit')]
public function editShopSeller(string $slug, Request $request, EntityManagerInterface $entityManager): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
$shop = $entityManager->getRepository(Shop::class)->findOneBy(['slug' => $slug]);
if (!$shop) {
$this->addFlash('error', 'Boutique non trouvée.');
return $this->redirectToRoute('seller_index');
}
// Vérifier que la boutique appartient à l'utilisateur connecté
if (!$shop->getManager()->contains($this->getUser())) {
$this->addFlash('error', 'Vous n\'avez pas la permission de modifier cette boutique.');
return $this->redirectToRoute('seller_shop_show', ['slug' => $shop->getSlug()]);
}
$form = $this->createForm(SellerShopType::class, $shop);
$form->handleRequest($request);
// Gérer les uploads AJAX partiels (images de bannière uniquement)
$isAjax = $request->isXmlHttpRequest() || $request->headers->get('X-Requested-With') === 'XMLHttpRequest';
$uploadedBannerImages = $request->files->all('bannerImages');
$isPartialUpload = $isAjax && !empty($uploadedBannerImages);
if ($isPartialUpload) {
// Upload AJAX partiel pour les images de bannière
$allBannerImages = $shop->getAllBannerImages();
$maxSize = 15 * 1024 * 1024; // 15MB
foreach ($uploadedBannerImages as $file) {
if ($file && $file->isValid()) {
if ($file->getSize() > $maxSize) {
return new JsonResponse([
'success' => false,
'message' => 'Le fichier "' . $file->getClientOriginalName() . '" est trop volumineux. Taille maximale : 15MB.'
], 400);
}
if (count($allBannerImages) >= 7) {
return new JsonResponse([
'success' => false,
'message' => 'Maximum 7 images de bannière autorisées.'
], 400);
}
$newFilename = uniqid().'.'.$file->guessExtension();
$file->move(
$this->getParameter('kernel.project_dir').'/public/uploads/shops/',
$newFilename
);
$allBannerImages[] = 'uploads/shops/'.$newFilename;
}
}
$allBannerImages = array_values(array_unique($allBannerImages));
$shop->setBannerImages($allBannerImages);
$entityManager->flush();
return new JsonResponse([
'success' => true,
'message' => count($uploadedBannerImages) . ' image(s) ajoutée(s) avec succès',
'data' => [
'bannerImages' => $shop->getAllBannerImages()
]
]);
}
if ($form->isSubmitted() && $form->isValid()) {
// Génération automatique du slug si le nom a changé
if ($shop->getName()) {
$slugger = new AsciiSlugger();
$newSlug = strtolower($slugger->slug($shop->getName())->toString());
// Vérifier si le slug existe déjà pour une autre boutique
$existingShop = $entityManager->getRepository(Shop::class)->findOneBy(['slug' => $newSlug]);
if (!$existingShop || $existingShop->getId() === $shop->getId()) {
$shop->setSlug($newSlug);
}
}
// Gestion des fichiers uploadés
$logoFile = $form->get('logo')->getData();
if ($logoFile && $logoFile->isValid()) {
$maxSize = 15 * 1024 * 1024; // 15MB
if ($logoFile->getSize() > $maxSize) {
$this->addFlash('error', 'Le logo est trop volumineux. Taille maximale : 15MB.');
} else {
$newFilename = uniqid().'.'.$logoFile->guessExtension();
$logoFile->move(
$this->getParameter('kernel.project_dir').'/public/uploads/shops/',
$newFilename
);
$shop->setLogo('uploads/shops/'.$newFilename);
}
}
// Gestion des images de bannière (jusqu'à 7 images, 15MB max chacune)
$allBannerImages = $shop->getAllBannerImages();
// Gérer la suppression d'images existantes
$removeBannerImages = $request->request->all('removeBannerImages');
if ($removeBannerImages) {
foreach ($removeBannerImages as $imageToRemove) {
$allBannerImages = array_filter($allBannerImages, function($img) use ($imageToRemove) {
return $img !== $imageToRemove;
});
}
$allBannerImages = array_values($allBannerImages);
}
// Gestion des nouvelles images multiples de bannière
$bannerImagesFile = $form->get('bannerImages')->getData();
if ($bannerImagesFile) {
$maxSize = 15 * 1024 * 1024; // 15MB
foreach ($bannerImagesFile as $file) {
if ($file && $file->isValid()) {
// Vérifier la taille du fichier
if ($file->getSize() > $maxSize) {
$this->addFlash('error', 'Le fichier "' . $file->getClientOriginalName() . '" est trop volumineux. Taille maximale : 15MB.');
continue;
}
$newFilename = uniqid().'.'.$file->guessExtension();
$file->move(
$this->getParameter('kernel.project_dir').'/public/uploads/shops/',
$newFilename
);
$allBannerImages[] = 'uploads/shops/'.$newFilename;
}
}
}
// Dédupliquer et limiter à 7 images max
$allBannerImages = array_values(array_unique($allBannerImages));
if (count($allBannerImages) > 7) {
$this->addFlash('error', 'Maximum 7 images de bannière autorisées. Les images supplémentaires ont été ignorées.');
$allBannerImages = array_slice($allBannerImages, 0, 7);
}
$shop->setBannerImages($allBannerImages);
// Contraintes basées sur le plan sélectionné
if ($shop->getPlan()) {
$plan = $shop->getPlan();
$errors = [];
// Vérifier les champs requis par le plan
if ($plan->isRequireSiretNif() && !$shop->getSiretNif()) {
$errors[] = 'Votre plan exige un SIRET/NIF. Veuillez le renseigner.';
}
if ($plan->isRequireIban() && !$shop->getIban()) {
$errors[] = 'Votre plan exige un IBAN. Veuillez le renseigner.';
}
// Si des erreurs, retourner au formulaire
if (!empty($errors)) {
foreach ($errors as $error) {
$this->addFlash('error', $error);
}
// Récupérer tous les plans pour les contraintes JavaScript
$shopPlans = $entityManager->getRepository(ShopPlan::class)->findAll();
$plansData = [];
foreach ($shopPlans as $planItem) {
$plansData[$planItem->getId()] = [
'id' => $planItem->getId(),
'name' => $planItem->getName(),
'requireSiretNif' => $planItem->isRequireSiretNif(),
'requireIban' => $planItem->isRequireIban(),
'maxProducts' => $planItem->getMaxProducts(),
'maxEmployees' => $planItem->getMaxEmployees(),
];
}
return $this->render('seller/new_shop_simple.html.twig', [
'current_menu' => 'shop',
'current' => 'shopEdit',
'shop' => $shop,
'form' => $form->createView(),
'plansData' => $plansData
]);
}
}
$entityManager->flush();
$this->addFlash('success', 'Boutique modifiée avec succès !');
return $this->redirectToRoute('seller_shop_show', ['slug' => $shop->getSlug()], Response::HTTP_SEE_OTHER);
}
// Récupérer tous les plans pour les contraintes JavaScript
$shopPlans = $entityManager->getRepository(ShopPlan::class)->findAll();
$plansData = [];
foreach ($shopPlans as $plan) {
$plansData[$plan->getId()] = [
'id' => $plan->getId(),
'name' => $plan->getName(),
'requireSiretNif' => $plan->isRequireSiretNif(),
'requireIban' => $plan->isRequireIban(),
'maxProducts' => $plan->getMaxProducts(),
'maxEmployees' => $plan->getMaxEmployees(),
];
}
return $this->render('seller/new_shop_simple.html.twig', [
'current_menu' => 'shop',
'current' => 'shopEdit',
'shop' => $shop,
'form' => $form->createView(),
'plansData' => $plansData
]);
}
#[Route('/shop/{slug}/show/products', name: 'shop_show_products')]
public function showProductsSeller(string $slug, Request $request, EntityManagerInterface $em): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $slug]);
if (!$shop) {
$this->addFlash('error', 'Boutique non trouvée.');
return $this->redirectToRoute('seller_index');
}
// Filtrer uniquement les produits actifs pour le vendeur
$products = $shop->getProducts()->filter(function($product) {
return $product->isIsActive();
});
$categories = $em->getRepository(Category::class)->findAll();
$data = [];
foreach ($products as $product) {
$tierPrices = $product->getTierPrices();
$hasTierPricing = $product->hasTierPricing() || ($tierPrices && count($tierPrices) > 0);
$data[] = [
'id' => $product->getId(),
'slug' => $product->getSlug(),
'image' => $product->getImages()[0] ?? null,
'name' => $product->getName(),
'price' => $product->getPrice(),
'stock' => $product->getStock(),
'status' => $product->getStockStatus(),
'hasTierPricing' => $hasTierPricing,
'isActive' => $product->isIsActive(),
];
}
return $this->render('seller/show_products.html.twig', [
'current_menu' => 'shop',
'current' => 'products',
'productsJson' => json_encode($data),
'categoriesJson' => json_encode(array_map(static function(Category $c){
return ['id' => $c->getId(), 'name' => $c->getName()];
}, $categories)),
'slugShop' => $shop->getSlug(),
'shop' => $shop
]);
}
#[Route('/shop/{slug}/products/new', name: 'shop_products_new', methods: ['GET', 'POST'])]
public function createProductForShop(string $slug, Request $request, EntityManagerInterface $em, ShopLimitService $shopLimitService): Response
{
if (!$this->getUser() || !$this->isAuthorized()) {
if ($request->isMethod('GET')) {
return $this->redirectToRoute('ui_app_login');
}
return new JsonResponse(['ok' => false, 'message' => 'Non autorisé'], 401);
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $slug]);
if (!$shop) {
if ($request->isMethod('GET')) {
$this->addFlash('error', 'Boutique non trouvée.');
return $this->redirectToRoute('seller_index');
}
return new JsonResponse(['ok' => false, 'message' => 'Boutique non trouvée'], 404);
}
// Vérifier que la boutique appartient au vendeur
if (!$shop->getManager()->contains($this->getUser())) {
if ($request->isMethod('GET')) {
$this->addFlash('error', 'Vous n\'avez pas accès à cette boutique.');
return $this->redirectToRoute('seller_index');
}
return new JsonResponse(['ok' => false, 'message' => 'Vous n\'avez pas accès à cette boutique.'], 403);
}
// Si GET, afficher le formulaire de création
if ($request->isMethod('GET')) {
// Vérifier les limites du plan avant d'afficher le formulaire
$productLimitCheck = $shopLimitService->canShopAddProduct($shop);
$categories = $em->getRepository(Category::class)->findAll();
// Récupérer les attributs disponibles pour les variantes
$attributes = $em->getRepository(\App\Entity\ProductAttribute::class)->findActiveOrdered();
$attributesData = [];
foreach ($attributes as $attr) {
$values = [];
foreach ($attr->getValues() as $value) {
if ($value->isIsActive()) {
$values[] = [
'id' => $value->getId(),
'value' => $value->getValue(),
'colorCode' => $value->getColorCode(),
'image' => $value->getImage(),
];
}
}
$attributesData[] = [
'id' => $attr->getId(),
'name' => $attr->getName(),
'slug' => $attr->getSlug(),
'type' => $attr->getType(),
'values' => $values,
];
}
return $this->render('seller/product/new.html.twig', [
'shop' => $shop,
'categories' => $categories,
'productLimitCheck' => $productLimitCheck,
'attributes' => $attributesData,
]);
}
// Si POST, vérifier les limites avant de créer le produit
$productLimitCheck = $shopLimitService->canShopAddProduct($shop);
if (!$productLimitCheck['allowed']) {
$isAjax = $request->isXmlHttpRequest() || $request->headers->get('X-Requested-With') === 'XMLHttpRequest';
if ($isAjax) {
return new JsonResponse(['ok' => false, 'message' => $productLimitCheck['message']], 403);
}
$this->addFlash('error', $productLimitCheck['message']);
$categories = $em->getRepository(Category::class)->findAll();
return $this->render('seller/product/new.html.twig', [
'shop' => $shop,
'categories' => $categories,
'productLimitCheck' => $productLimitCheck,
]);
}
// Si POST, créer le produit
$product = new Product();
$name = trim((string) $request->request->get('name'));
$description = (string) $request->request->get('description');
$price = (float) $request->request->get('price');
$compareAtPrice = $request->request->get('compareAtPrice') !== null ? (float) $request->request->get('compareAtPrice') : null;
$stock = (int) $request->request->get('stock');
$minStockAlert = $request->request->get('minStockAlert') !== null ? (int) $request->request->get('minStockAlert') : 0;
$manageStock = (bool) $request->request->get('manageStock');
$allowBackorders = (bool) $request->request->get('allowBackorders');
$isFeatured = (bool) $request->request->get('isFeatured');
$isDigital = (bool) $request->request->get('isDigital');
$stockStatus = (string) $request->request->get('stockStatus');
$categoryId = (int) $request->request->get('category');
// Définir $isAjax pour les validations
$isAjax = $request->isXmlHttpRequest() || $request->headers->get('X-Requested-With') === 'XMLHttpRequest';
if ($name === '' || $price <= 0 || $stock < 0 || !$categoryId) {
if ($isAjax) {
return new JsonResponse(['ok' => false, 'message' => 'Champs requis manquants'], 400);
}
$this->addFlash('error', 'Champs requis manquants ou invalides.');
return $this->redirectToRoute('seller_shop_products_new', ['slug' => $shop->getSlug()]);
}
$category = $em->getRepository(Category::class)->find($categoryId);
if (!$category) {
if ($isAjax) {
return new JsonResponse(['ok' => false, 'message' => 'Catégorie invalide'], 400);
}
$this->addFlash('error', 'Catégorie invalide.');
return $this->redirectToRoute('seller_shop_products_new', ['slug' => $shop->getSlug()]);
}
$slugger = new AsciiSlugger();
$slug = strtolower($slugger->slug($name)->toString());
$sku = 'SKU-' . strtoupper(substr(md5($name.microtime()), 0, 8));
$product->setName($name);
$product->setDescription($description ?: null);
$product->setPrice($price);
$product->setCompareAtPrice($compareAtPrice);
$product->setStock($stock);
$product->setMinStockAlert($minStockAlert);
$product->setManageStock($manageStock);
$product->setAllowBackorders($allowBackorders);
$product->setIsFeatured($isFeatured);
$product->setIsDigital($isDigital);
$product->setStockStatus($stockStatus ?: 'In stock');
$product->setCategory($category);
$product->setShop($shop);
$product->setSlug($slug);
$product->setSku($sku);
$product->setIsActive(true);
$product->setPublishedAt(new \DateTimeImmutable('now'));
$product->setViewCount(0);
$product->setSalesCount(0);
$product->setAverageRating(0);
$product->setReviewCount(0);
// Prix de gros (facultatif, à l'initiative du vendeur)
$enableTierPricing = (bool) $request->request->get('enableTierPricing');
$tierMins = $request->request->all('tierMin');
$tierPrices = $request->request->all('tierPrice');
$normalizedTiers = [];
if ($enableTierPricing && is_array($tierMins) && is_array($tierPrices)) {
$count = min(count($tierMins), count($tierPrices));
for ($i = 0; $i < $count; $i++) {
$m = (int) ($tierMins[$i] ?? 0);
$p = (float) ($tierPrices[$i] ?? 0);
if ($m >= 1 && $p > 0) {
$normalizedTiers[$m] = $p; // utiliser la quantité min comme clé pour dédupliquer
}
}
if (!empty($normalizedTiers)) {
ksort($normalizedTiers, SORT_NUMERIC);
$tiersArray = [];
foreach ($normalizedTiers as $minQty => $priceVal) {
$tiersArray[] = [ 'min' => (int)$minQty, 'price' => (float)$priceVal ];
}
$product->setHasTierPricing(true);
$product->setTierPrices($tiersArray);
} else {
$product->setHasTierPricing(false);
$product->setTierPrices(null);
}
} else {
$product->setHasTierPricing(false);
$product->setTierPrices(null);
}
// Définir $isAjax pour les blocs catch
$isAjax = $request->isXmlHttpRequest() || $request->headers->get('X-Requested-With') === 'XMLHttpRequest';
// Uploads
try {
$projectDir = $this->getParameter('kernel.project_dir');
$baseUploadDir = $projectDir . '/public/uploads/products/' . $shop->getSlug();
$imagesDir = $baseUploadDir . '/images';
$videosDir = $baseUploadDir . '/videos';
$documentsDir = $baseUploadDir . '/documents';
foreach ([$imagesDir, $videosDir, $documentsDir] as $dir) {
if (!is_dir($dir) && !@mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Impossible de créer le dossier: ' . $dir);
}
}
$sluggerFile = new AsciiSlugger();
$images = [];
$filesImages = $request->files->get('images', []);
if ($filesImages instanceof \Symfony\Component\HttpFoundation\File\UploadedFile) {
$filesImages = [$filesImages];
}
foreach ($filesImages as $file) {
if (!$file || !($file instanceof \Symfony\Component\HttpFoundation\File\UploadedFile) || !$file->isValid()) continue;
$ext = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin';
$orig = pathinfo((string)$file->getClientOriginalName(), PATHINFO_FILENAME);
$safeOrig = strtolower($sluggerFile->slug($orig)->toString());
$unique = bin2hex(random_bytes(8)) . '_' . str_replace('.', '', (string) microtime(true));
$filename = $safeOrig . '_' . $unique . '.' . $ext;
$file->move($imagesDir, $filename);
$images[] = '/uploads/products/' . $shop->getSlug() . '/images/' . $filename;
}
$videos = [];
$filesVideos = $request->files->get('videos', []);
if ($filesVideos instanceof \Symfony\Component\HttpFoundation\File\UploadedFile) {
$filesVideos = [$filesVideos];
}
foreach ($filesVideos as $file) {
if (!$file || !($file instanceof \Symfony\Component\HttpFoundation\File\UploadedFile) || !$file->isValid()) continue;
$ext = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin';
$orig = pathinfo((string)$file->getClientOriginalName(), PATHINFO_FILENAME);
$safeOrig = strtolower($sluggerFile->slug($orig)->toString());
$unique = bin2hex(random_bytes(8)) . '_' . str_replace('.', '', (string) microtime(true));
$filename = $safeOrig . '_' . $unique . '.' . $ext;
$file->move($videosDir, $filename);
$videos[] = '/uploads/products/' . $shop->getSlug() . '/videos/' . $filename;
}
$documents = [];
$filesDocuments = $request->files->get('documents', []);
if ($filesDocuments instanceof \Symfony\Component\HttpFoundation\File\UploadedFile) {
$filesDocuments = [$filesDocuments];
}
foreach ($filesDocuments as $file) {
if (!$file || !($file instanceof \Symfony\Component\HttpFoundation\File\UploadedFile) || !$file->isValid()) continue;
$ext = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin';
$orig = pathinfo((string)$file->getClientOriginalName(), PATHINFO_FILENAME);
$safeOrig = strtolower($sluggerFile->slug($orig)->toString());
$unique = bin2hex(random_bytes(8)) . '_' . str_replace('.', '', (string) microtime(true));
$filename = $safeOrig . '_' . $unique . '.' . $ext;
$file->move($documentsDir, $filename);
$documents[] = '/uploads/products/' . $shop->getSlug() . '/documents/' . $filename;
}
} catch (\Throwable $e) {
if ($isAjax) {
return new JsonResponse(['ok' => false, 'message' => 'Upload échoué: '.$e->getMessage()], 500);
}
$this->addFlash('error', 'Erreur lors de l\'upload des fichiers: '.$e->getMessage());
return $this->redirectToRoute('seller_shop_products_new', ['slug' => $shop->getSlug()]);
}
$product->setImages($images);
$product->setVideos($videos ?: null);
$product->setDocuments($documents ?: null);
// Gérer les variantes de produit
$variantsData = $request->request->all('variants') ?? [];
$variantsFiles = $request->files->all('variants') ?? [];
$attributeValueRepo = $em->getRepository(\App\Entity\ProductAttributeValue::class);
foreach ($variantsData as $variantIndex => $variantData) {
if (empty($variantData['price']) || empty($variantData['stock'])) {
continue; // Ignorer les variantes incomplètes
}
$variant = new \App\Entity\ProductVariant();
$variant->setProduct($product);
// Générer automatiquement le SKU si non fourni
if (empty($variantData['sku'])) {
$variantSku = 'SKU-' . strtoupper(substr(md5($name . microtime() . $variantIndex), 0, 8)) . '-' . uniqid();
$variant->setSku($variantSku);
} else {
$variant->setSku($variantData['sku']);
}
$variant->setPrice((float) $variantData['price']);
$variant->setStock((int) $variantData['stock']);
$variant->setCompareAtPrice(!empty($variantData['compareAtPrice']) ? (float) $variantData['compareAtPrice'] : null);
$variant->setManageStock($manageStock);
$variant->setAllowBackorders($allowBackorders);
$variant->setStockStatus($stockStatus ?: 'in_stock');
$variant->setIsActive(true);
$variant->setIsDefault(false);
// Gérer les attributs de la variante
if (!empty($variantData['attributes']) && is_array($variantData['attributes'])) {
foreach ($variantData['attributes'] as $attrData) {
if (!empty($attrData['valueId'])) {
$attrValue = $attributeValueRepo->find((int) $attrData['valueId']);
if ($attrValue && $attrValue->isIsActive()) {
$variant->addAttributeValue($attrValue);
}
}
}
}
// Gérer les images spécifiques à la variante
$variantImages = [];
if (isset($variantsFiles[$variantIndex]['images']) && is_array($variantsFiles[$variantIndex]['images'])) {
foreach ($variantsFiles[$variantIndex]['images'] as $file) {
if ($file && $file instanceof \Symfony\Component\HttpFoundation\File\UploadedFile && $file->isValid()) {
$ext = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin';
$orig = pathinfo((string)$file->getClientOriginalName(), PATHINFO_FILENAME);
$safeOrig = strtolower($sluggerFile->slug($orig)->toString());
$unique = bin2hex(random_bytes(8)) . '_' . str_replace('.', '', (string) microtime(true));
$filename = $safeOrig . '_' . $unique . '.' . $ext;
$file->move($imagesDir, $filename);
$variantImages[] = '/uploads/products/' . $shop->getSlug() . '/images/' . $filename;
}
}
}
$variant->setImages($variantImages);
$em->persist($variant);
}
// Gérer les détails supplémentaires
$detailsData = $request->request->all('details') ?? [];
foreach ($detailsData as $detailData) {
if (empty($detailData['label']) || empty($detailData['value'])) {
continue; // Ignorer les détails incomplets
}
$detail = new \App\Entity\ProductDetail();
$detail->setProduct($product);
$detail->setLabel(trim($detailData['label']));
$detail->setValue(trim($detailData['value']));
$detail->setIsActive(true);
$detail->setSortOrder(0);
$em->persist($detail);
}
try {
// Incrémente le compteur de produits de la catégorie sélectionnée
if ($category) {
$category->incrementProductCount();
$em->persist($category);
}
$em->persist($product);
$em->flush();
// Mettre à jour le compteur de produits de la boutique
$shopLimitService->updateProductCount($shop);
} catch (\Throwable $e) {
if ($isAjax) {
return new JsonResponse(['ok' => false, 'message' => 'Erreur enregistrement: '.$e->getMessage()], 500);
}
$this->addFlash('error', 'Erreur lors de l\'enregistrement: '.$e->getMessage());
return $this->redirectToRoute('seller_shop_products_new', ['slug' => $shop->getSlug()]);
}
// Rediriger vers la page d'édition du produit créé
if ($isAjax) {
return new JsonResponse([
'ok' => true,
'redirect' => $this->generateUrl('seller_product_edit', ['shopSlug' => $shop->getSlug(), 'id' => $product->getId()]),
'product' => [
'id' => $product->getId(),
'image' => $images[0] ?? null,
'name' => $product->getName(),
'price' => $product->getPrice(),
'stock' => $product->getStock(),
'status' => $product->getStockStatus(),
'hasTierPricing' => $product->hasTierPricing(),
]
]);
}
$this->addFlash('success', 'Produit créé avec succès !');
return $this->redirectToRoute('seller_shop_show', ['slug' => $shop->getSlug()]);
}
#[Route('/shop/{shopSlug}/products/{id}/show', name: 'product_show')]
// Note: Le préfixe 'seller_' fait que la route finale est 'seller_product_show'
public function showProductSeller(string $shopSlug, int $id, EntityManagerInterface $em): Response
{
if (!$this->getUser() || !$this->isAuthorized()) {
return $this->redirectToRoute('ui_app_login');
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $shopSlug]);
if (!$shop) {
$this->addFlash('error', 'Boutique non trouvée.');
return $this->redirectToRoute('seller_index');
}
// Vérifier que la boutique appartient au vendeur
if (!$shop->getManager()->contains($this->getUser())) {
$this->addFlash('error', 'Vous n\'avez pas accès à cette boutique.');
return $this->redirectToRoute('seller_index');
}
$product = $em->getRepository(Product::class)->find($id);
if (!$product || $product->getShop()->getId() !== $shop->getId()) {
throw $this->createNotFoundException('Produit non trouvé');
}
// Rendre le template seller pour voir le produit
return $this->render('seller/product/show.html.twig', [
'product' => $product,
'shop' => $shop,
]);
}
#[Route('/shop/{shopSlug}/products/{id}/edit', name: 'product_edit', methods: ['GET', 'POST'])]
// Note: Le préfixe 'seller_' fait que la route finale est 'seller_product_edit'
public function editProductSeller(string $shopSlug, int $id, Request $request, EntityManagerInterface $em): Response
{
if (!$this->getUser() || !$this->isAuthorized()) {
return $this->redirectToRoute('ui_app_login');
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $shopSlug]);
if (!$shop) {
$this->addFlash('error', 'Boutique non trouvée.');
return $this->redirectToRoute('seller_index');
}
// Vérifier que la boutique appartient au vendeur
if (!$shop->getManager()->contains($this->getUser())) {
$this->addFlash('error', 'Vous n\'avez pas accès à cette boutique.');
return $this->redirectToRoute('seller_index');
}
$product = $em->getRepository(Product::class)->find($id);
if (!$product || $product->getShop()->getId() !== $shop->getId()) {
throw $this->createNotFoundException('Produit non trouvé');
}
// Traitement de la soumission du formulaire (POST)
if ($request->isMethod('POST')) {
$isAjax = $request->isXmlHttpRequest() || $request->headers->get('X-Requested-With') === 'XMLHttpRequest';
$name = trim((string) $request->request->get('name'));
$description = (string) $request->request->get('description');
$price = (float) $request->request->get('price');
$compareAtPrice = $request->request->get('compareAtPrice') !== null && $request->request->get('compareAtPrice') !== '' ? (float) $request->request->get('compareAtPrice') : null;
$stock = (int) $request->request->get('stock');
$minStockAlert = $request->request->get('minStockAlert') !== null ? (int) $request->request->get('minStockAlert') : 0;
// Valeurs par défaut gérées en backend
$manageStock = true; // Gérer le stock par défaut
$allowBackorders = false; // Pas de précommandes par défaut
$isFeatured = false; // Pas en vedette par défaut
$stockStatus = 'In stock'; // En stock par défaut
$isDigital = (bool) $request->request->get('isDigital');
$categoryId = (int) $request->request->get('category');
$isActive = (bool) $request->request->get('isActive');
// Si c'est un upload partiel via AJAX, on peut ignorer la validation des champs de base
// à condition que des fichiers soient présents
$uploadedImages = $request->files->all('images');
$uploadedVideos = $request->files->all('videos');
$uploadedDocuments = $request->files->all('documents');
$isPartialUpload = $isAjax && (!empty($uploadedImages) || !empty($uploadedVideos) || !empty($uploadedDocuments));
if (!$isPartialUpload) {
if ($name === '' || $price <= 0 || $stock < 0 || !$categoryId) {
if ($isAjax) {
return new JsonResponse(['success' => false, 'message' => 'Champs requis manquants ou invalides.'], 400);
}
$this->addFlash('error', 'Champs requis manquants ou invalides.');
return $this->redirectToRoute('seller_product_edit', ['shopSlug' => $shop->getSlug(), 'id' => $product->getId()]);
}
$category = $em->getRepository(Category::class)->find($categoryId);
if (!$category) {
if ($isAjax) {
return new JsonResponse(['success' => false, 'message' => 'Catégorie invalide.'], 400);
}
$this->addFlash('error', 'Catégorie invalide.');
return $this->redirectToRoute('seller_product_edit', ['shopSlug' => $shop->getSlug(), 'id' => $product->getId()]);
}
// Mettre à jour le slug si le nom a changé
if ($product->getName() !== $name) {
$slugger = new AsciiSlugger();
$slug = strtolower($slugger->slug($name)->toString());
$product->setSlug($slug);
}
$product->setName($name);
$product->setDescription($description ?: null);
$product->setPrice($price);
$product->setCompareAtPrice($compareAtPrice);
$product->setStock($stock);
$product->setMinStockAlert($minStockAlert);
$product->setManageStock($manageStock);
$product->setAllowBackorders($allowBackorders);
$product->setIsFeatured($isFeatured);
$product->setIsDigital($isDigital);
$product->setStockStatus($stockStatus ?: 'In stock');
$product->setCategory($category);
$product->setIsActive($isActive);
// Prix de gros (Tier Pricing)
$enableTierPricing = (bool) $request->request->get('enableTierPricing');
$tierMins = $request->request->all('tierMin');
$tierPrices = $request->request->all('tierPrice');
$normalizedTiers = [];
if ($enableTierPricing && is_array($tierMins) && is_array($tierPrices)) {
$count = min(count($tierMins), count($tierPrices));
for ($i = 0; $i < $count; $i++) {
$m = (int) ($tierMins[$i] ?? 0);
$p = (float) ($tierPrices[$i] ?? 0);
if ($m >= 1 && $p > 0) {
$normalizedTiers[$m] = $p;
}
}
if (!empty($normalizedTiers)) {
ksort($normalizedTiers, SORT_NUMERIC);
$tiersArray = [];
foreach ($normalizedTiers as $minQty => $priceVal) {
$tiersArray[] = [ 'min' => (int)$minQty, 'price' => (float)$priceVal ];
}
$product->setHasTierPricing(true);
$product->setTierPrices($tiersArray);
} else {
$product->setHasTierPricing(false);
$product->setTierPrices(null);
}
} else {
$product->setHasTierPricing(false);
$product->setTierPrices(null);
}
}
// Gestion des fichiers (images, vidéos, documents)
$images = $product->getImages();
$videos = $product->getVideos();
$documents = $product->getDocuments();
// Upload des nouvelles images
if (!empty($uploadedImages)) {
$productDir = $this->getParameter('kernel.project_dir') . '/public/uploads/products/' . $product->getId();
if (!is_dir($productDir)) {
mkdir($productDir, 0755, true);
}
foreach ($uploadedImages as $file) {
if ($file && $file->isValid()) {
$filename = uniqid() . '.' . $file->guessExtension();
$file->move($productDir, $filename);
$images[] = '/uploads/products/' . $product->getId() . '/' . $filename;
}
}
}
// Upload des nouvelles vidéos
$uploadedVideos = $request->files->all('videos');
if (!empty($uploadedVideos)) {
if ($videos === null) $videos = [];
$productDir = $this->getParameter('kernel.project_dir') . '/public/uploads/products/' . $product->getId() . '/videos';
if (!is_dir($productDir)) {
mkdir($productDir, 0755, true);
}
foreach ($uploadedVideos as $file) {
if ($file && $file->isValid()) {
$filename = uniqid() . '.' . $file->guessExtension();
$file->move($productDir, $filename);
$videos[] = '/uploads/products/' . $product->getId() . '/videos/' . $filename;
}
}
}
// Upload des nouveaux documents
$uploadedDocuments = $request->files->all('documents');
if (!empty($uploadedDocuments)) {
if ($documents === null) $documents = [];
$productDir = $this->getParameter('kernel.project_dir') . '/public/uploads/products/' . $product->getId() . '/documents';
if (!is_dir($productDir)) {
mkdir($productDir, 0755, true);
}
foreach ($uploadedDocuments as $file) {
if ($file && $file->isValid()) {
$filename = uniqid() . '.' . $file->guessExtension();
$file->move($productDir, $filename);
$documents[] = '/uploads/products/' . $product->getId() . '/documents/' . $filename;
}
}
}
$product->setImages($images);
$product->setVideos($videos ?: null);
$product->setDocuments($documents ?: null);
// Gérer les variantes de produit
$variantsData = $request->request->all('variants') ?? [];
$variantsFiles = $request->files->all('variants') ?? [];
$attributeValueRepo = $em->getRepository(\App\Entity\ProductAttributeValue::class);
$variantRepo = $em->getRepository(\App\Entity\ProductVariant::class);
$sluggerFile = new AsciiSlugger();
// Récupérer toutes les variantes existantes
$existingVariants = [];
foreach ($product->getVariants() as $existingVariant) {
$existingVariants[$existingVariant->getId()] = $existingVariant;
}
// Traiter les variantes soumises
foreach ($variantsData as $variantKey => $variantData) {
// Vérifier si c'est une suppression
if (!empty($variantData['delete'])) {
if (isset($existingVariants[$variantKey])) {
$existingVariants[$variantKey]->setIsActive(false);
}
continue;
}
// Vérifier si c'est une variante existante à mettre à jour
if (is_numeric($variantKey) && isset($existingVariants[$variantKey])) {
$variant = $existingVariants[$variantKey];
// Le SKU est préservé via le champ caché, pas besoin de le modifier
$variant->setPrice((float) $variantData['price']);
$variant->setStock((int) $variantData['stock']);
$variant->setCompareAtPrice(!empty($variantData['compareAtPrice']) ? (float) $variantData['compareAtPrice'] : null);
// Mettre à jour les attributs
$variant->getAttributeValues()->clear();
if (!empty($variantData['attributes']) && is_array($variantData['attributes'])) {
foreach ($variantData['attributes'] as $attrData) {
if (!empty($attrData['valueId'])) {
$attrValue = $attributeValueRepo->find((int) $attrData['valueId']);
if ($attrValue && $attrValue->isIsActive()) {
$variant->addAttributeValue($attrValue);
}
}
}
}
// Gérer les nouvelles images de variante
if (isset($variantsFiles[$variantKey]['images']) && is_array($variantsFiles[$variantKey]['images'])) {
$variantImages = $variant->getImages();
$productDir = $this->getParameter('kernel.project_dir') . '/public/uploads/products/' . $product->getId();
foreach ($variantsFiles[$variantKey]['images'] as $file) {
if ($file && $file instanceof \Symfony\Component\HttpFoundation\File\UploadedFile && $file->isValid()) {
$ext = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin';
$orig = pathinfo((string)$file->getClientOriginalName(), PATHINFO_FILENAME);
$safeOrig = strtolower($sluggerFile->slug($orig)->toString());
$unique = bin2hex(random_bytes(8)) . '_' . str_replace('.', '', (string) microtime(true));
$filename = $safeOrig . '_' . $unique . '.' . $ext;
$file->move($productDir, $filename);
$variantImages[] = '/uploads/products/' . $product->getId() . '/' . $filename;
}
}
$variant->setImages($variantImages);
}
} else {
// Nouvelle variante
if (empty($variantData['price']) || empty($variantData['stock'])) {
continue;
}
$variant = new \App\Entity\ProductVariant();
$variant->setProduct($product);
// Générer automatiquement le SKU si non fourni
if (empty($variantData['sku'])) {
$variantSku = 'SKU-' . strtoupper(substr(md5($name . microtime() . $variantKey), 0, 8)) . '-' . uniqid();
$variant->setSku($variantSku);
} else {
$variant->setSku($variantData['sku']);
}
$variant->setPrice((float) $variantData['price']);
$variant->setStock((int) $variantData['stock']);
$variant->setCompareAtPrice(!empty($variantData['compareAtPrice']) ? (float) $variantData['compareAtPrice'] : null);
$variant->setManageStock($manageStock);
$variant->setAllowBackorders($allowBackorders);
$variant->setStockStatus($stockStatus ?: 'in_stock');
$variant->setIsActive(true);
$variant->setIsDefault(false);
// Gérer les attributs
if (!empty($variantData['attributes']) && is_array($variantData['attributes'])) {
foreach ($variantData['attributes'] as $attrData) {
if (!empty($attrData['valueId'])) {
$attrValue = $attributeValueRepo->find((int) $attrData['valueId']);
if ($attrValue && $attrValue->isIsActive()) {
$variant->addAttributeValue($attrValue);
}
}
}
}
// Gérer les images
$variantImages = [];
if (isset($variantsFiles[$variantKey]['images']) && is_array($variantsFiles[$variantKey]['images'])) {
$productDir = $this->getParameter('kernel.project_dir') . '/public/uploads/products/' . $product->getId();
foreach ($variantsFiles[$variantKey]['images'] as $file) {
if ($file && $file instanceof \Symfony\Component\HttpFoundation\File\UploadedFile && $file->isValid()) {
$ext = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin';
$orig = pathinfo((string)$file->getClientOriginalName(), PATHINFO_FILENAME);
$safeOrig = strtolower($sluggerFile->slug($orig)->toString());
$unique = bin2hex(random_bytes(8)) . '_' . str_replace('.', '', (string) microtime(true));
$filename = $safeOrig . '_' . $unique . '.' . $ext;
$file->move($productDir, $filename);
$variantImages[] = '/uploads/products/' . $product->getId() . '/' . $filename;
}
}
}
$variant->setImages($variantImages);
$em->persist($variant);
}
}
// Supprimer les variantes qui n'ont pas été soumises (si nécessaire)
// Pour l'instant, on garde toutes les variantes existantes sauf celles marquées pour suppression
// Gérer les détails supplémentaires
$detailsData = $request->request->all('details') ?? [];
$detailRepo = $em->getRepository(\App\Entity\ProductDetail::class);
// Récupérer tous les détails existants
$existingDetails = [];
foreach ($product->getDetails() as $existingDetail) {
$existingDetails[$existingDetail->getId()] = $existingDetail;
}
// Traiter les détails soumis
foreach ($detailsData as $detailKey => $detailData) {
// Vérifier si c'est une suppression
if (!empty($detailData['delete'])) {
if (isset($existingDetails[$detailKey])) {
$existingDetails[$detailKey]->setIsActive(false);
}
continue;
}
// Vérifier si c'est un détail existant à mettre à jour
if (is_numeric($detailKey) && isset($existingDetails[$detailKey])) {
$detail = $existingDetails[$detailKey];
$detail->setLabel(trim($detailData['label']));
$detail->setValue(trim($detailData['value']));
} else {
// Nouveau détail
if (empty($detailData['label']) || empty($detailData['value'])) {
continue;
}
$detail = new \App\Entity\ProductDetail();
$detail->setProduct($product);
$detail->setLabel(trim($detailData['label']));
$detail->setValue(trim($detailData['value']));
$detail->setIsActive(true);
$detail->setSortOrder(0);
$em->persist($detail);
}
}
try {
$em->flush();
// Si c'est une requête AJAX (upload de fichiers), retourner du JSON
if ($request->isXmlHttpRequest() || $request->headers->get('X-Requested-With') === 'XMLHttpRequest' || !empty($uploadedImages) || !empty($uploadedVideos) || !empty($uploadedDocuments)) {
// Si la requête contient uniquement des fichiers, on peut considérer que c'est un upload partiel
// Sinon, c'est peut-être la soumission globale via AJAX
return new JsonResponse([
'success' => true,
'message' => 'Produit mis à jour avec succès',
'data' => [
'images' => $product->getImages(),
'videos' => $product->getVideos(),
'documents' => $product->getDocuments()
]
]);
}
$this->addFlash('success', 'Produit modifié avec succès.');
return $this->redirectToRoute('seller_product_show', ['shopSlug' => $shop->getSlug(), 'id' => $product->getId()]);
} catch (\Throwable $e) {
if ($request->isXmlHttpRequest() || $request->headers->get('X-Requested-With') === 'XMLHttpRequest') {
return new JsonResponse(['success' => false, 'message' => 'Erreur lors de la modification: ' . $e->getMessage()], 500);
}
$this->addFlash('error', 'Erreur lors de la modification: ' . $e->getMessage());
}
}
// Récupérer les catégories pour le formulaire
$categories = $em->getRepository(Category::class)->findAll();
// Récupérer les attributs disponibles pour les variantes
$attributes = $em->getRepository(\App\Entity\ProductAttribute::class)->findActiveOrdered();
$attributesData = [];
foreach ($attributes as $attr) {
$values = [];
foreach ($attr->getValues() as $value) {
if ($value->isIsActive()) {
$values[] = [
'id' => $value->getId(),
'value' => $value->getValue(),
'colorCode' => $value->getColorCode(),
'image' => $value->getImage(),
];
}
}
$attributesData[] = [
'id' => $attr->getId(),
'name' => $attr->getName(),
'slug' => $attr->getSlug(),
'type' => $attr->getType(),
'values' => $values,
];
}
// Rendre le template seller pour modifier le produit
return $this->render('seller/product/edit.html.twig', [
'product' => $product,
'shop' => $shop,
'categories' => $categories,
'attributes' => $attributesData,
]);
}
#[Route('/shop/{shopSlug}/products/{id}/image/delete', name: 'product_image_delete', methods: ['POST'])]
public function deleteProductImage(string $shopSlug, int $id, Request $request, EntityManagerInterface $em): JsonResponse
{
if (!$this->getUser() || !$this->isAuthorized()) {
return new JsonResponse(['success' => false, 'message' => 'Non autorisé'], 401);
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $shopSlug]);
if (!$shop) {
return new JsonResponse(['success' => false, 'message' => 'Boutique non trouvée'], 404);
}
// Vérifier que la boutique appartient au vendeur
if (!$shop->getManager()->contains($this->getUser())) {
return new JsonResponse(['success' => false, 'message' => 'Vous n\'avez pas accès à cette boutique.'], 403);
}
$product = $em->getRepository(Product::class)->find($id);
if (!$product || $product->getShop()->getId() !== $shop->getId()) {
return new JsonResponse(['success' => false, 'message' => 'Produit non trouvé'], 404);
}
$imagePath = $request->request->get('image');
if (!$imagePath) {
return new JsonResponse(['success' => false, 'message' => 'Chemin d\'image manquant'], 400);
}
// Normaliser le chemin (s'assurer qu'il commence par /)
$normalizedPath = ltrim($imagePath, '/');
$normalizedPath = '/' . $normalizedPath;
$images = $product->getImages();
// Essayer de trouver l'image avec différentes variantes du chemin
$imageIndex = false;
foreach ($images as $index => $storedImage) {
// Normaliser le chemin stocké aussi
$normalizedStored = ltrim($storedImage, '/');
$normalizedStored = '/' . $normalizedStored;
// Comparer les chemins normalisés ou originaux
if ($normalizedStored === $normalizedPath ||
$storedImage === $imagePath ||
$storedImage === $normalizedPath ||
$storedImage === ltrim($imagePath, '/')) {
$imageIndex = $index;
$imagePath = $storedImage; // Utiliser le chemin exact stocké
break;
}
}
if ($imageIndex === false) {
return new JsonResponse([
'success' => false,
'message' => 'Image non trouvée dans la liste',
'debug' => [
'requested' => $imagePath,
'normalized_requested' => $normalizedPath,
'available_images' => $images
]
], 404);
}
// Supprimer l'image du tableau
unset($images[$imageIndex]);
$images = array_values($images); // Réindexer le tableau
$product->setImages($images);
// Supprimer le fichier physique - utiliser le chemin exact stocké
$filePathToDelete = $this->getParameter('kernel.project_dir') . '/public' . $imagePath;
// Essayer aussi avec le chemin normalisé si différent
if (!file_exists($filePathToDelete) && $imagePath !== $normalizedPath) {
$filePathToDelete = $this->getParameter('kernel.project_dir') . '/public' . $normalizedPath;
}
if (file_exists($filePathToDelete)) {
@unlink($filePathToDelete);
}
try {
$em->flush();
return new JsonResponse([
'success' => true,
'message' => 'Image supprimée avec succès',
'data' => [
'images' => $product->getImages(),
'videos' => $product->getVideos(),
'documents' => $product->getDocuments()
]
]);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => 'Erreur lors de la suppression: ' . $e->getMessage()], 500);
}
}
#[Route('/shop/{shopSlug}/products/{id}/video/delete', name: 'product_video_delete', methods: ['POST'])]
public function deleteProductVideo(string $shopSlug, int $id, Request $request, EntityManagerInterface $em): JsonResponse
{
if (!$this->getUser() || !$this->isAuthorized()) {
return new JsonResponse(['success' => false, 'message' => 'Non autorisé'], 401);
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $shopSlug]);
if (!$shop) {
return new JsonResponse(['success' => false, 'message' => 'Boutique non trouvée'], 404);
}
if (!$shop->getManager()->contains($this->getUser())) {
return new JsonResponse(['success' => false, 'message' => 'Vous n\'avez pas accès à cette boutique.'], 403);
}
$product = $em->getRepository(Product::class)->find($id);
if (!$product || $product->getShop()->getId() !== $shop->getId()) {
return new JsonResponse(['success' => false, 'message' => 'Produit non trouvé'], 404);
}
$videoPath = $request->request->get('video');
if (!$videoPath) {
return new JsonResponse(['success' => false, 'message' => 'Chemin de vidéo manquant'], 400);
}
$normalizedPath = ltrim($videoPath, '/');
$normalizedPath = '/' . $normalizedPath;
$videos = $product->getVideos() ?: [];
$videoIndex = false;
foreach ($videos as $index => $storedVideo) {
$normalizedStored = ltrim($storedVideo, '/');
$normalizedStored = '/' . $normalizedStored;
if ($normalizedStored === $normalizedPath ||
$storedVideo === $videoPath ||
$storedVideo === $normalizedPath ||
$storedVideo === ltrim($videoPath, '/')) {
$videoIndex = $index;
$videoPath = $storedVideo;
break;
}
}
if ($videoIndex === false) {
return new JsonResponse(['success' => false, 'message' => 'Vidéo non trouvée dans la liste'], 404);
}
unset($videos[$videoIndex]);
$videos = array_values($videos);
$product->setVideos(empty($videos) ? null : $videos);
$filePathToDelete = $this->getParameter('kernel.project_dir') . '/public' . $videoPath;
if (!file_exists($filePathToDelete) && $videoPath !== $normalizedPath) {
$filePathToDelete = $this->getParameter('kernel.project_dir') . '/public' . $normalizedPath;
}
if (file_exists($filePathToDelete)) {
@unlink($filePathToDelete);
}
try {
$em->flush();
return new JsonResponse([
'success' => true,
'message' => 'Vidéo supprimée avec succès',
'data' => [
'images' => $product->getImages(),
'videos' => $product->getVideos(),
'documents' => $product->getDocuments()
]
]);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => 'Erreur lors de la suppression: ' . $e->getMessage()], 500);
}
}
#[Route('/shop/{shopSlug}/products/{id}/document/delete', name: 'product_document_delete', methods: ['POST'])]
public function deleteProductDocument(string $shopSlug, int $id, Request $request, EntityManagerInterface $em): JsonResponse
{
if (!$this->getUser() || !$this->isAuthorized()) {
return new JsonResponse(['success' => false, 'message' => 'Non autorisé'], 401);
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $shopSlug]);
if (!$shop) {
return new JsonResponse(['success' => false, 'message' => 'Boutique non trouvée'], 404);
}
if (!$shop->getManager()->contains($this->getUser())) {
return new JsonResponse(['success' => false, 'message' => 'Vous n\'avez pas accès à cette boutique.'], 403);
}
$product = $em->getRepository(Product::class)->find($id);
if (!$product || $product->getShop()->getId() !== $shop->getId()) {
return new JsonResponse(['success' => false, 'message' => 'Produit non trouvé'], 404);
}
$documentPath = $request->request->get('document');
if (!$documentPath) {
return new JsonResponse(['success' => false, 'message' => 'Chemin de document manquant'], 400);
}
$normalizedPath = ltrim($documentPath, '/');
$normalizedPath = '/' . $normalizedPath;
$documents = $product->getDocuments() ?: [];
$documentIndex = false;
foreach ($documents as $index => $storedDocument) {
$normalizedStored = ltrim($storedDocument, '/');
$normalizedStored = '/' . $normalizedStored;
if ($normalizedStored === $normalizedPath ||
$storedDocument === $documentPath ||
$storedDocument === $normalizedPath ||
$storedDocument === ltrim($documentPath, '/')) {
$documentIndex = $index;
$documentPath = $storedDocument;
break;
}
}
if ($documentIndex === false) {
return new JsonResponse(['success' => false, 'message' => 'Document non trouvé dans la liste'], 404);
}
unset($documents[$documentIndex]);
$documents = array_values($documents);
$product->setDocuments(empty($documents) ? null : $documents);
$filePathToDelete = $this->getParameter('kernel.project_dir') . '/public' . $documentPath;
if (!file_exists($filePathToDelete) && $documentPath !== $normalizedPath) {
$filePathToDelete = $this->getParameter('kernel.project_dir') . '/public' . $normalizedPath;
}
if (file_exists($filePathToDelete)) {
@unlink($filePathToDelete);
}
try {
$em->flush();
return new JsonResponse([
'success' => true,
'message' => 'Document supprimé avec succès',
'data' => [
'images' => $product->getImages(),
'videos' => $product->getVideos(),
'documents' => $product->getDocuments()
]
]);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => 'Erreur lors de la suppression: ' . $e->getMessage()], 500);
}
}
#[Route('/shop/{slug}/banner-image/delete', name: 'shop_banner_image_delete', methods: ['POST'])]
public function deleteShopBannerImage(string $slug, Request $request, EntityManagerInterface $em): JsonResponse
{
if (!$this->getUser() || !$this->isAuthorized()) {
return new JsonResponse(['success' => false, 'message' => 'Non autorisé'], 401);
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $slug]);
if (!$shop) {
return new JsonResponse(['success' => false, 'message' => 'Boutique non trouvée'], 404);
}
// Vérifier que la boutique appartient au vendeur
if (!$shop->getManager()->contains($this->getUser())) {
return new JsonResponse(['success' => false, 'message' => 'Vous n\'avez pas accès à cette boutique.'], 403);
}
$imagePath = $request->request->get('image');
if (!$imagePath) {
return new JsonResponse(['success' => false, 'message' => 'Chemin d\'image manquant'], 400);
}
$bannerImages = $shop->getAllBannerImages();
$normalizedPath = ltrim($imagePath, '/');
$imageIndex = -1;
foreach ($bannerImages as $index => $bannerImage) {
$normalizedBannerImage = ltrim($bannerImage, '/');
if ($bannerImage === $imagePath || $normalizedBannerImage === $normalizedPath || $bannerImage === $normalizedPath || $normalizedBannerImage === $imagePath) {
$imageIndex = $index;
break;
}
}
if ($imageIndex === -1) {
return new JsonResponse(['success' => false, 'message' => 'Image non trouvée'], 404);
}
unset($bannerImages[$imageIndex]);
$bannerImages = array_values($bannerImages);
$shop->setBannerImages(empty($bannerImages) ? [] : $bannerImages);
$filePathToDelete = $this->getParameter('kernel.project_dir') . '/public/' . ltrim($imagePath, '/');
if (!file_exists($filePathToDelete) && $imagePath !== $normalizedPath) {
$filePathToDelete = $this->getParameter('kernel.project_dir') . '/public/' . $normalizedPath;
}
if (file_exists($filePathToDelete)) {
@unlink($filePathToDelete);
}
try {
$em->flush();
return new JsonResponse([
'success' => true,
'message' => 'Image de bannière supprimée avec succès',
'data' => [
'bannerImages' => $shop->getAllBannerImages()
]
]);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => 'Erreur lors de la suppression: ' . $e->getMessage()], 500);
}
}
#[Route('/shop/{shopSlug}/products/{id}/delete', name: 'product_delete', methods: ['POST'])]
public function deleteProduct(string $shopSlug, int $id, EntityManagerInterface $em): JsonResponse
{
if (!$this->getUser() || !$this->isAuthorized()) {
return new JsonResponse(['success' => false, 'message' => 'Non autorisé'], 401);
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $shopSlug]);
if (!$shop) {
return new JsonResponse(['success' => false, 'message' => 'Boutique non trouvée'], 404);
}
// Vérifier que la boutique appartient au vendeur
if (!$shop->getManager()->contains($this->getUser())) {
return new JsonResponse(['success' => false, 'message' => 'Vous n\'avez pas accès à cette boutique.'], 403);
}
$product = $em->getRepository(Product::class)->find($id);
if (!$product || $product->getShop()->getId() !== $shop->getId()) {
return new JsonResponse(['success' => false, 'message' => 'Produit non trouvé'], 404);
}
// Cacher le produit au lieu de le supprimer
try {
$product->setIsActive(false);
$em->flush();
return new JsonResponse([
'success' => true,
'message' => 'Produit supprimé avec succès',
'hidden' => true
]);
} catch (\Throwable $e) {
return new JsonResponse([
'success' => false,
'message' => 'Erreur lors de la suppression: ' . $e->getMessage()
], 500);
}
}
#[Route('/check-slug', name: 'shop_check_slug', methods: ['GET'])]
public function checkSlug(Request $request, EntityManagerInterface $em): JsonResponse
{
$slug = $request->query->get('slug');
if (!$slug) {
return new JsonResponse(['valid' => false, 'message' => 'Slug manquant'], 400);
}
$exists = $em->getRepository(Shop::class)->findOneBy(['slug' => $slug]);
return new JsonResponse([
'valid' => $exists ? false : true,
'message' => $exists ? 'Ce slug est déjà utilisé' : 'Slug disponible'
]);
}
#[Route('/pricing', name: 'pricing')]
public function pricingSeller(ShopPlanRepository $shopPlanRepository): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
$allPlans = $shopPlanRepository->findAll();
return $this->render('seller/pricing.html.twig', [
'current_menu' => 'pricing',
'allPlans' => $allPlans
]);
}
#[Route('/faq', name: 'faq')]
public function faqSeller(): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
return $this->render('seller/faq.html.twig', [
'current_menu' => 'faq'
]);
}
#[Route('/terms', name: 'terms')]
public function terms(): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
return $this->render('seller/terms.html.twig', [
'current_menu' => 'shop'
]);
}
#[Route('/privacy', name: 'privacy')]
public function privacy(): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
return $this->render('seller/privacy.html.twig', [
'current_menu' => 'shop'
]);
}
#[Route('/shop/{slug}/orders', name: 'shop_orders')]
public function shopOrders(string $slug, EntityManagerInterface $em): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $slug]);
if (!$shop) {
$this->addFlash('error', 'Boutique non trouvée.');
return $this->redirectToRoute('seller_index');
}
// Vérifier que la boutique appartient à l'utilisateur connecté
if (!$shop->getManager()->contains($this->getUser())) {
$this->addFlash('error', 'Vous n\'avez pas accès à cette boutique.');
return $this->redirectToRoute('seller_index');
}
// TODO: Implémenter la logique pour afficher les commandes
return $this->render('seller/orders.html.twig', [
'shop' => $shop,
'current_menu' => 'shop'
]);
}
#[Route('/shop/{slug}/transactions', name: 'shop_transactions')]
public function shopTransactions(string $slug, EntityManagerInterface $em): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $slug]);
if (!$shop) {
$this->addFlash('error', 'Boutique non trouvée.');
return $this->redirectToRoute('seller_index');
}
// Vérifier que la boutique appartient à l'utilisateur connecté
if (!$shop->getManager()->contains($this->getUser())) {
$this->addFlash('error', 'Vous n\'avez pas accès à cette boutique.');
return $this->redirectToRoute('seller_index');
}
// TODO: Implémenter la logique pour afficher les transactions
return $this->render('seller/transactions.html.twig', [
'shop' => $shop,
'current_menu' => 'shop'
]);
}
#[Route('/shop/{slug}/team', name: 'shop_team')]
public function shopTeam(string $slug, Request $request, EntityManagerInterface $em, ShopLimitService $shopLimitService): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $slug]);
if (!$shop) {
$this->addFlash('error', 'Boutique non trouvée.');
return $this->redirectToRoute('seller_index');
}
// Vérifier que la boutique appartient à l'utilisateur connecté
if (!$shop->getManager()->contains($this->getUser())) {
$this->addFlash('error', 'Vous n\'avez pas accès à cette boutique.');
return $this->redirectToRoute('seller_index');
}
// Gérer l'ajout d'un membre
if ($request->isMethod('POST') && $request->request->has('add_member')) {
$email = trim($request->request->get('email', ''));
if (empty($email)) {
$this->addFlash('error', 'L\'email est requis.');
} else {
// Vérifier la limite d'employés avec le service
$employeeLimitCheck = $shopLimitService->canShopAddEmployee($shop);
if (!$employeeLimitCheck['allowed']) {
$this->addFlash('error', $employeeLimitCheck['message']);
return $this->redirectToRoute('seller_shop_team', ['slug' => $slug]);
}
// Vérifier aussi que l'utilisateur à ajouter n'a pas atteint sa limite de boutiques
$userRepo = $em->getRepository(User::class);
$userToAdd = $userRepo->findOneBy(['email' => $email]);
if (!$userToAdd) {
$this->addFlash('error', 'Aucun utilisateur trouvé avec cet email.');
} elseif ($shop->getManager()->contains($userToAdd)) {
$this->addFlash('warning', 'Cet utilisateur fait déjà partie de l\'équipe.');
} else {
// Vérifier que l'utilisateur peut être membre d'une nouvelle boutique
$userShopLimitCheck = $shopLimitService->canUserCreateShop($userToAdd);
if (!$userShopLimitCheck['allowed']) {
$this->addFlash('error', sprintf(
'Cet utilisateur a déjà atteint sa limite de %d boutiques. Il ne peut pas être ajouté à une nouvelle boutique.',
$userShopLimitCheck['max_count']
));
} else {
$shop->addManager($userToAdd);
$shop->setCurrentEmployees($shop->getManager()->count());
$em->flush();
$this->addFlash('success', 'Membre ajouté à l\'équipe avec succès.');
}
}
}
return $this->redirectToRoute('seller_shop_team', ['slug' => $slug]);
}
// Gérer la suppression d'un membre
if ($request->isMethod('POST') && $request->request->has('remove_member')) {
$memberId = (int) $request->request->get('member_id');
$userRepo = $em->getRepository(User::class);
$member = $userRepo->find($memberId);
$currentUser = $this->getUser();
if (!$member) {
$this->addFlash('error', 'Membre non trouvé.');
} elseif ($currentUser instanceof User && $member->getId() === $currentUser->getId()) {
$this->addFlash('error', 'Vous ne pouvez pas vous retirer vous-même de l\'équipe.');
} elseif (!$shop->getManager()->contains($member)) {
$this->addFlash('error', 'Ce membre ne fait pas partie de l\'équipe.');
} else {
$shop->removeManager($member);
$shop->setCurrentEmployees($shop->getManager()->count());
$em->flush();
$this->addFlash('success', 'Membre retiré de l\'équipe avec succès.');
}
return $this->redirectToRoute('seller_shop_team', ['slug' => $slug]);
}
// Récupérer tous les membres de l'équipe
$teamMembers = $shop->getManager()->toArray();
// Récupérer les informations du plan pour l'affichage
$plan = $shop->getPlan();
$maxEmployees = $plan ? $plan->getMaxEmployees() : null;
$currentEmployeesCount = count($teamMembers);
$canAddMore = $maxEmployees === null || $currentEmployeesCount < $maxEmployees;
return $this->render('seller/team.html.twig', [
'shop' => $shop,
'teamMembers' => $teamMembers,
'plan' => $plan,
'maxEmployees' => $maxEmployees,
'currentEmployeesCount' => $currentEmployeesCount,
'canAddMore' => $canAddMore,
'current_menu' => 'shop'
]);
}
private function isAuthorized(): bool
{
if (!$this->isGranted('IS_AUTHENTICATED_FULLY')) {
return false;
}
$user = $this->getUser();
return $user && in_array('ROLE_SELLER', $user->getRoles());
}
}