<?php
namespace App\Controller;
use App\Entity\Shop;
use App\Entity\Product;
use App\Entity\Category;
use App\Entity\ShopPlan;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use App\Entity\User;
use App\Form\SellerShopType;
use App\Repository\ShopPlanRepository;
use App\Service\NotificationService;
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(): 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/index.html.twig', [
'current_menu' => 'home'
]);
}
#[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): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
if (!$this->isAuthorized()) {
return $this->render('misc/access_denied.html.twig');
}
$shop = new Shop();
$form = $this->createForm(SellerShopType::class, $shop);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$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');
}
// 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('noreply@maketou.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
]);
}
#[Route('/shop/{slug}/show', name: 'shop_show')]
public function showShopSeller(#[MapEntity(mapping: ['slug' => 'slug'])] Shop $shop, 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');
}
// Vérifier que la boutique appartient à l'utilisateur connecté
$canEdit = $shop->getManager()->contains($this->getUser());
// Calculer les statistiques dynamiques
$productsCount = $shop->getActiveProductsCount();
$totalProducts = $shop->getProducts()->count();
$ordersCount = $shop->getCurrentOrders() ?? 0;
$revenue = (float)($shop->getCurrentRevenue() ?? 0);
// 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) {
// Essayer de trouver la dernière commande via les produits
// Pour l'instant, on utilise la date de création
}
// 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';
}
}
}
// Récupérer les commandes récentes de la boutique (si l'entité Order existe)
$recentOrders = [];
try {
$orderRepo = $em->getRepository(\App\Entity\Order::class);
$recentOrders = $orderRepo->createQueryBuilder('o')
->join('o.items', 'oi')
->join('oi.product', 'p')
->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, on continue sans
}
// 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
}
return $this->render('seller/show_shop.html.twig', [
'current_menu' => 'shop',
'slugShop' => $shop->getSlug(),
'shop' => $shop,
'canEdit' => $canEdit,
'recentOrders' => $recentOrders,
'viewsLast30Days' => $viewsLast30Days,
'stats' => [
'productsCount' => $productsCount,
'totalProducts' => $totalProducts,
'ordersCount' => $ordersCount,
'revenue' => $revenue,
'availableBalance' => $availableBalance,
'lastUpdate' => $updateText
]
]);
}
#[Route('/shop/{slug}/edit', name: 'shop_edit')]
public function editShopSeller(#[MapEntity(mapping: ['slug' => 'slug'])] Shop $shop, 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');
}
// 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);
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(#[MapEntity(mapping: ['slug' => 'slug'])] Shop $shop, 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');
}
$products = $shop->getProducts();
$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,
];
}
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: ['POST'])]
public function createProductForShop(#[MapEntity(mapping: ['slug' => 'slug'])] Shop $shop, Request $request, EntityManagerInterface $em): Response
{
if (!$this->getUser() || !$this->isAuthorized()) {
return new JsonResponse(['ok' => false, 'message' => 'Non autorisé'], 401);
}
$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');
if ($name === '' || $price <= 0 || $stock < 0 || !$categoryId) {
return new JsonResponse(['ok' => false, 'message' => 'Champs requis manquants'], 400);
}
$category = $em->getRepository(Category::class)->find($categoryId);
if (!$category) {
return new JsonResponse(['ok' => false, 'message' => 'Catégorie invalide'], 400);
}
$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);
}
// 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) {
return new JsonResponse(['ok' => false, 'message' => 'Upload échoué: '.$e->getMessage()], 500);
}
$product->setImages($images);
$product->setVideos($videos ?: null);
$product->setDocuments($documents ?: null);
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();
} catch (\Throwable $e) {
return new JsonResponse(['ok' => false, 'message' => 'Erreur enregistrement: '.$e->getMessage()], 500);
}
return new JsonResponse([
'ok' => true,
'product' => [
'id' => $product->getId(),
'image' => $images[0] ?? null,
'name' => $product->getName(),
'price' => $product->getPrice(),
'stock' => $product->getStock(),
'status' => $product->getStockStatus(),
'hasTierPricing' => $product->hasTierPricing(),
]
]);
}
#[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(#[MapEntity(mapping: ['shopSlug' => 'slug'])] Shop $shop, int $id, EntityManagerInterface $em): Response
{
if (!$this->getUser() || !$this->isAuthorized()) {
return $this->redirectToRoute('ui_app_login');
}
// 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(#[MapEntity(mapping: ['shopSlug' => 'slug'])] Shop $shop, int $id, Request $request, EntityManagerInterface $em): Response
{
if (!$this->getUser() || !$this->isAuthorized()) {
return $this->redirectToRoute('ui_app_login');
}
// 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')) {
$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;
$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');
$isActive = (bool) $request->request->get('isActive');
if ($name === '' || $price <= 0 || $stock < 0 || !$categoryId) {
$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) {
$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
$uploadedImages = $request->files->all('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);
try {
$em->flush();
$this->addFlash('success', 'Produit modifié avec succès.');
return $this->redirectToRoute('seller_product_show', ['shopSlug' => $shop->getSlug(), 'id' => $product->getId()]);
} catch (\Throwable $e) {
$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();
// Rendre le template seller pour modifier le produit
return $this->render('seller/product/edit.html.twig', [
'product' => $product,
'shop' => $shop,
'categories' => $categories,
]);
}
#[Route('/shop/{shopSlug}/products/{id}/image/delete', name: 'product_image_delete', methods: ['POST'])]
public function deleteProductImage(#[MapEntity(mapping: ['shopSlug' => 'slug'])] Shop $shop, int $id, Request $request, EntityManagerInterface $em): JsonResponse
{
if (!$this->getUser() || !$this->isAuthorized()) {
return new JsonResponse(['success' => false, 'message' => 'Non autorisé'], 401);
}
// 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);
}
$images = $product->getImages();
$imageIndex = array_search($imagePath, $images);
if ($imageIndex === false) {
return new JsonResponse(['success' => false, 'message' => 'Image non trouvée'], 404);
}
// Supprimer l'image du tableau
unset($images[$imageIndex]);
$images = array_values($images); // Réindexer le tableau
$product->setImages($images);
// Supprimer le fichier physique
$filePath = $this->getParameter('kernel.project_dir') . '/public' . $imagePath;
if (file_exists($filePath)) {
@unlink($filePath);
}
try {
$em->flush();
return new JsonResponse([
'success' => true,
'message' => 'Image supprimée avec succès',
'remainingImages' => count($images)
]);
} 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'
]);
}
private function isAuthorized(): bool
{
if (!$this->isGranted('IS_AUTHENTICATED_FULLY')) {
return false;
}
$user = $this->getUser();
return $user && in_array('ROLE_SELLER', $user->getRoles());
}
}