<?php
namespace App\Controller;
use App\Entity\Address;
use App\Entity\Brand;
use App\Entity\Cart;
use App\Entity\ProductCondition;
use App\Entity\CartItem;
use App\Entity\User;
use App\Entity\Order;
use App\Entity\PaymentMethod;
use App\Entity\Product;
use App\Entity\Category;
use App\Entity\ShippingMethod;
use App\Entity\Shop;
use App\Entity\ShopCategory;
use App\Form\AddressType;
use App\Form\KycFormType;
use App\Form\RegistrationFormType;
use App\Repository\AddressRepository;
use App\Repository\ProductRepository;
use App\Security\EmailVerifier;
use App\Service\NotificationService;
use App\Service\RecommendationService;
use App\Service\ProductComparisonService;
use App\Service\ViewTrackingService;
use App\Service\ShopFollowService;
use App\Service\WishlistService;
use App\Service\MonCashService;
use App\Service\DropshipService;
use App\Service\GiftCardService;
use App\Form\GiftCardPurchaseType;
use App\Entity\GiftCard;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mime\Address as EmailAddress;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
use App\Form\ResetPasswordRequestFormType;
use App\Form\ResetPasswordFormType;
use App\Repository\ShopFollowRepository;
use App\Service\PointService;
use App\Service\ReferralService;
use App\Service\CacheService;
#[Route('/', name: 'ui_')]
class UIController extends AbstractController
{
private EmailVerifier $emailVerifier;
private EntityManagerInterface $entityManager;
private WishlistService $wishlistService;
private ProductComparisonService $comparisonService;
private MonCashService $monCashService;
private DropshipService $dropshipService;
private RecommendationService $recommendationService;
public function __construct(EmailVerifier $emailVerifier,
EntityManagerInterface $entityManager,
WishlistService $wishlistService,
ProductComparisonService $comparisonService,
MonCashService $monCashService,
DropshipService $dropshipService,
RecommendationService $recommendationService,
private PointService $pointService,
private ReferralService $referralService,
private NotificationService $notificationService
)
{
$this->emailVerifier = $emailVerifier;
$this->entityManager = $entityManager;
$this->wishlistService = $wishlistService;
$this->comparisonService = $comparisonService;
$this->monCashService = $monCashService;
$this->dropshipService = $dropshipService;
$this->recommendationService = $recommendationService;
}
#[Route('/api/search/suggest', name: 'api_search_suggest', methods: ['GET'])]
public function searchSuggest(Request $request, EntityManagerInterface $em): JsonResponse
{
$query = trim((string) $request->query->get('q', ''));
$limitPerType = 5;
if ($query === '') {
return $this->json(['success' => true, 'results' => []]);
}
$results = [];
// Products
$products = $em->createQueryBuilder()
->select('p')
->from(Product::class, 'p')
->where('p.isActive = :active')
->andWhere('LOWER(p.name) LIKE :q')
->setParameter('active', true)
->setParameter('q', '%' . mb_strtolower($query) . '%')
->setMaxResults($limitPerType)
->getQuery()
->getResult();
foreach ($products as $product) {
$results[] = [
'type' => 'product',
'label' => $product->getName(),
'url' => $this->generateUrl('ui_product_show', ['slug' => $product->getSlug()])
];
}
// Shops
$shops = $em->createQueryBuilder()
->select('s')
->from(Shop::class, 's')
->where('s.isActive = :active')
->andWhere('LOWER(s.name) LIKE :q')
->setParameter('active', true)
->setParameter('q', '%' . mb_strtolower($query) . '%')
->setMaxResults($limitPerType)
->getQuery()
->getResult();
foreach ($shops as $shop) {
$results[] = [
'type' => 'shop',
'label' => $shop->getName(),
'url' => $this->generateUrl('ui_shop_show', ['slug' => $shop->getSlug()])
];
}
// Categories
$categories = $em->createQueryBuilder()
->select('c')
->from(Category::class, 'c')
->where('c.isActive = :active')
->andWhere('LOWER(c.name) LIKE :q')
->setParameter('active', true)
->setParameter('q', '%' . mb_strtolower($query) . '%')
->setMaxResults($limitPerType)
->getQuery()
->getResult();
foreach ($categories as $category) {
$results[] = [
'type' => 'category',
'label' => $category->getName(),
'url' => $this->generateUrl('ui_listing', ['category' => $category->getSlug()])
];
}
// Brands
$brands = $em->createQueryBuilder()
->select('b')
->from(Brand::class, 'b')
->where('b.isActive = :active')
->andWhere('LOWER(b.name) LIKE :q')
->setParameter('active', true)
->setParameter('q', '%' . mb_strtolower($query) . '%')
->setMaxResults($limitPerType)
->getQuery()
->getResult();
foreach ($brands as $brand) {
$results[] = [
'type' => 'brand',
'label' => $brand->getName(),
'url' => $this->generateUrl('ui_listing', ['brand' => $brand->getSlug()])
];
}
return $this->json([
'success' => true,
'results' => $results,
]);
}
#[Route('/', name: 'home')]
public function index(
EntityManagerInterface $entityManager,
ViewTrackingService $viewTrackingService,
CacheService $cacheService
): Response {
// Utiliser le cache pour les données fréquemment accédées
// Pour le banner, limiter à 5 produits en vedette
$bannerProducts = $cacheService->getBannerProducts(10);
// Pour la galerie, utiliser jusqu'à 10 produits en vedette
$featuredProducts = $cacheService->getFeaturedProducts(10);
$latestProducts = $cacheService->getLatestProducts(12);
$categories = $cacheService->getPopularCategories(8);
$stats = $cacheService->getStats();
// Limiter à 6 catégories de boutiques (moins fréquemment changées, pas besoin de cache)
$shopCategories = $entityManager->getRepository(\App\Entity\ShopCategory::class)->findBy([
'isActive' => true,
'parent' => null
], ['position' => 'ASC'], 6);
$user = $this->getUser();
$recommendedProducts = [];
$recentlyViewedProducts = $viewTrackingService->getRecentlyViewedProducts(8);
if ($user instanceof User) {
$recommendedProducts = $this->recommendationService->getPersonalizedRecommendations($user, 8);
} else {
// Utiliser la méthode optimisée du repository
$recommendedProducts = $entityManager->getRepository(Product::class)->findPopularProducts(8);
}
// Toutes les catégories pour le bouton "Voir tout" (avec cache implicite via Doctrine)
$allCategories = $entityManager->getRepository(Category::class)->findBy([
'isActive' => true
], ['name' => 'ASC']);
// Toutes les catégories de boutiques pour le bouton "Voir tout"
$allShopCategories = $entityManager->getRepository(\App\Entity\ShopCategory::class)->findBy([
'isActive' => true,
'parent' => null
], ['position' => 'ASC']);
return $this->render('home/index.html.twig', [
'current_menu' => 'home',
'bannerProducts' => $bannerProducts, // Produits pour le banner (max 5)
'featuredProducts' => $featuredProducts, // Produits pour la galerie (max 10)
'latestProducts' => $latestProducts,
'categories' => $categories,
'allCategories' => $allCategories,
'shopCategories' => $shopCategories,
'allShopCategories' => $allShopCategories,
'stats' => $stats,
'recommendedProducts' => $recommendedProducts,
'recentlyViewedProducts' => $recentlyViewedProducts,
]);
}
#[Route('/newsletter/subscribe', name: 'newsletter_subscribe', methods: ['POST'])]
public function newsletterSubscribe(Request $request): JsonResponse
{
$email = $request->request->get('email');
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->json(['success' => false, 'message' => 'Adresse email invalide'], 400);
}
// On va sauvegarder l'email dans la table NewsletterSubscriber (à créer si pas déjà là)
$em = $this->getDoctrine()->getManager();
$existing = $em->getRepository(\App\Entity\NewsletterSubscriber::class)->findOneBy(['email' => $email]);
if ($existing) {
return $this->json([
'success' => false,
'message' => 'Cet email est déjà inscrit à notre newsletter.'
], 409);
}
$subscriber = new \App\Entity\NewsletterSubscriber();
$subscriber->setEmail($email);
$subscriber->setSubscribedAt(new \DateTimeImmutable());
$em->persist($subscriber);
$em->flush();
return $this->json([
'success' => true,
'message' => 'Merci pour votre inscription à notre newsletter !'
]);
}
#[Route('/shops', name: 'shops_list')]
public function shopsList(Request $request, EntityManagerInterface $em, ShopFollowRepository $shopFollowRepository): Response
{
$categorySlug = $request->query->get('category');
$page = $request->query->getInt('page', 1);
$limit = 9;
$offset = ($page - 1) * $limit;
$qb = $em->getRepository(Shop::class)->createQueryBuilder('s')
->where('s.isActive = :active')
->setParameter('active', true);
if ($categorySlug) {
$qb->leftJoin('s.shopCategory', 'sc')
->andWhere('sc.slug = :categorySlug')
->setParameter('categorySlug', $categorySlug);
}
// Compter le total avant de limiter
$totalShops = (clone $qb)->select('COUNT(s.id)')
->getQuery()
->getSingleScalarResult();
$shops = $qb->orderBy('s.createdAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
$totalPages = ceil($totalShops / $limit);
// Si c'est une requête AJAX, retourner JSON
if ($request->isXmlHttpRequest()) {
$user = $this->getUser();
$shopsData = [];
foreach ($shops as $shop) {
$isFollowing = false;
if ($user) {
$isFollowing = $shopFollowRepository->isUserFollowingShop($user, $shop);
}
$shopsData[] = [
'id' => $shop->getId(),
'name' => $shop->getName(),
'slug' => $shop->getSlug(),
'logo' => $shop->getLogo(),
'description' => $shop->getDescription(),
'isVerified' => $shop->isVerified(),
'category' => $shop->getShopCategory() ? $shop->getShopCategory()->getName() : null,
'productsCount' => $shop->getActiveProductsCount(),
'followersCount' => $shop->getActiveFollowersCount(),
'viewCount' => $shop->getViewCount(),
'following' => $isFollowing,
];
}
return $this->json([
'success' => true,
'shops' => $shopsData,
'hasMore' => $page < $totalPages,
'currentPage' => $page,
'totalPages' => $totalPages,
]);
}
$shopCategories = $em->getRepository(ShopCategory::class)->findBy(['isActive' => true], ['name' => 'ASC']);
// Récupérer la catégorie sélectionnée si un slug est fourni
$selectedCategoryEntity = null;
if ($categorySlug) {
$selectedCategoryEntity = $em->getRepository(ShopCategory::class)->findOneBy(['slug' => $categorySlug, 'isActive' => true]);
}
// Récupérer les IDs des boutiques suivies par l'utilisateur connecté (pour optimiser l'affichage)
$followedShopIds = [];
$user = $this->getUser();
if ($user) {
$followedShops = $shopFollowRepository->createQueryBuilder('sf')
->select('s.id')
->join('sf.shop', 's')
->where('sf.user = :user')
->andWhere('sf.isActive = :active')
->setParameter('user', $user)
->setParameter('active', true)
->getQuery()
->getResult();
$followedShopIds = array_column($followedShops, 'id');
}
return $this->render('home/shops_list.html.twig', [
'current_menu' => 'shops',
'shops' => $shops,
'shopCategories' => $shopCategories,
'selectedCategory' => $selectedCategoryEntity,
'selectedCategorySlug' => $categorySlug,
'current_page' => $page,
'total_pages' => $totalPages,
'total_shops' => $totalShops,
'shops_per_page' => $limit,
'followed_shop_ids' => $followedShopIds,
]);
}
#[Route('/listing', name: 'listing')]
public function listing(Request $request, EntityManagerInterface $em, ShopFollowRepository $shopFollowRepository): Response
{
$categorySlug = $request->query->get('category');
$brandSlug = $request->query->get('brand');
$conditionSlug = $request->query->get('condition');
$sortBy = $request->query->get('sort', 'newest');
$priceMin = $request->query->get('price_min');
$priceMax = $request->query->get('price_max');
$page = $request->query->getInt('page', 1);
$searchQuery = trim($request->query->get('q', ''));
// Récupérer les catégories
$categories = $em->getRepository(Category::class)->findBy(['isActive' => true], ['name' => 'ASC']);
// Récupérer les marques
$brands = $em->getRepository(Brand::class)->findBy(['isActive' => true], ['name' => 'ASC']);
// Récupérer les conditions
$conditions = $em->getRepository(ProductCondition::class)->findBy(['isActive' => true], ['name' => 'ASC']);
// Construire la requête pour les produits
$qb = $em->getRepository(Product::class)->createQueryBuilder('p')
->where('p.isActive = :active')
->setParameter('active', true);
// Filtre par recherche textuelle
if ($searchQuery) {
$qb->andWhere('(LOWER(p.name) LIKE :searchQuery OR LOWER(p.description) LIKE :searchQuery OR LOWER(p.sku) LIKE :searchQuery)')
->setParameter('searchQuery', '%' . mb_strtolower($searchQuery) . '%');
}
// Filtre par catégorie
if ($categorySlug) {
$qb->andWhere('p.category = :category')
->setParameter('category', $em->getRepository(Category::class)->findOneBy(['slug' => $categorySlug]));
}
// Filtre par marque
if ($brandSlug) {
if ($brandSlug === 'non-specifie') {
$qb->andWhere('p.brand IS NULL');
} else {
$qb->andWhere('p.brand = :brand')
->setParameter('brand', $em->getRepository(Brand::class)->findOneBy(['slug' => $brandSlug]));
}
}
// Filtre par condition
if ($conditionSlug) {
if ($conditionSlug === 'non-specifie') {
$qb->andWhere('p.condition IS NULL');
} else {
$qb->andWhere('p.condition = :condition')
->setParameter('condition', $em->getRepository(ProductCondition::class)->findOneBy(['slug' => $conditionSlug]));
}
}
// Filtre par prix
if ($priceMin) {
$qb->andWhere('p.price >= :priceMin')
->setParameter('priceMin', $priceMin);
}
if ($priceMax) {
$qb->andWhere('p.price <= :priceMax')
->setParameter('priceMax', $priceMax);
}
// Tri
switch ($sortBy) {
case 'rank':
case 'best_seller':
// Trier par ranking Amazon (si catégorie sélectionnée)
if ($categorySlug) {
$category = $em->getRepository(Category::class)->findOneBy(['slug' => $categorySlug]);
if ($category) {
$qb->leftJoin('App\Entity\ProductRanking', 'pr', 'WITH',
'pr.product = p AND pr.category = :rankingCategory')
->setParameter('rankingCategory', $category)
->orderBy('pr.rank', 'ASC')
->addOrderBy('pr.score', 'DESC');
break;
}
}
// Fallback: tri par score global (ventes + vues + notes)
$qb->orderBy('p.salesCount', 'DESC')
->addOrderBy('p.viewCount', 'DESC')
->addOrderBy('p.averageRating', 'DESC');
break;
case 'price_asc':
$qb->orderBy('p.price', 'ASC');
break;
case 'price_desc':
$qb->orderBy('p.price', 'DESC');
break;
case 'name_asc':
$qb->orderBy('p.name', 'ASC');
break;
case 'name_desc':
$qb->orderBy('p.name', 'DESC');
break;
case 'popular':
$qb->orderBy('p.viewCount', 'DESC');
break;
default:
$qb->orderBy('p.publishedAt', 'DESC');
}
// Pagination (9 par page, comme les boutiques)
$productsPerPage = 9;
$offset = ($page - 1) * $productsPerPage;
$qb->setFirstResult($offset)
->setMaxResults($productsPerPage);
$products = $qb->getQuery()->getResult();
// Compter le total pour la pagination
$totalQuery = clone $qb;
$totalQuery->setFirstResult(0)->setMaxResults(null);
$totalProducts = count($totalQuery->getQuery()->getResult());
$totalPages = ceil($totalProducts / $productsPerPage);
// Réponse AJAX pour "Charger plus" (même principe que les boutiques)
if ($request->isXmlHttpRequest()) {
return $this->json([
'success' => true,
'products' => $this->renderView('home/_listing_products_ajax.html.twig', [
'products' => $products,
]),
'pagination' => [
'currentPage' => $page,
'totalPages' => $totalPages,
'totalProducts' => $totalProducts,
],
]);
}
return $this->render('home/listing.html.twig', [
'current_menu' => 'listing',
'products' => $products,
'categories' => $categories,
'brands' => $brands,
'conditions' => $conditions,
'currentCategory' => $categorySlug,
'currentBrand' => $brandSlug,
'currentCondition' => $conditionSlug ?? '',
'currentSort' => $sortBy,
'currentPage' => $page,
'totalPages' => $totalPages,
'totalProducts' => $totalProducts,
'priceMin' => $priceMin,
'priceMax' => $priceMax,
'q' => $searchQuery,
]);
}
#[Route('/api/products/featured', name: 'api_products_featured', methods: ['GET'])]
public function getFeaturedProducts(Request $request, EntityManagerInterface $em): JsonResponse
{
$page = $request->query->getInt('page', 1);
$limit = $request->query->getInt('limit', 10);
$offset = ($page - 1) * $limit;
$featuredProducts = $em->getRepository(Product::class)->findBy(
['isFeatured' => true, 'isActive' => true],
['publishedAt' => 'DESC'],
$limit,
$offset
);
$total = $em->getRepository(Product::class)->count([
'isFeatured' => true,
'isActive' => true
]);
$products = [];
foreach ($featuredProducts as $product) {
$products[] = [
'id' => $product->getId(),
'name' => $product->getName(),
'slug' => $product->getSlug(),
'price' => $product->getPrice(),
'description' => $product->getDescription() ? substr($product->getDescription(), 0, 120) . '...' : null,
'image' => $product->getImages()[0] ?? null,
'shop' => $product->getShop() ? [
'name' => $product->getShop()->getName(),
'slug' => $product->getShop()->getSlug()
] : null,
'averageRating' => $product->getAverageRating(),
'reviewCount' => $product->getReviewCount(),
'viewCount' => $product->getViewCount(),
'url' => $this->generateUrl('ui_product_show', ['slug' => $product->getSlug()])
];
}
return $this->json([
'success' => true,
'products' => $products,
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'hasMore' => ($offset + $limit) < $total
]
]);
}
#[Route('/api/products/filter', name: 'api_products_filter', methods: ['GET'])]
public function filterProducts(Request $request, EntityManagerInterface $em): JsonResponse
{
$categorySlug = $request->query->get('category');
$brandSlug = $request->query->get('brand');
$conditionSlug = $request->query->get('condition');
$sortBy = $request->query->get('sort', 'newest');
$priceMin = $request->query->get('price_min');
$priceMax = $request->query->get('price_max');
$page = $request->query->getInt('page', 1);
$searchQuery = trim($request->query->get('q', ''));
// Nouveaux filtres avancés
$shopSlug = $request->query->get('shop');
$isFeatured = $request->query->get('featured');
$isDigital = $request->query->get('digital');
$stockStatus = $request->query->get('stock_status');
$ratingMin = $request->query->get('rating_min');
$weightMin = $request->query->get('weight_min');
$weightMax = $request->query->get('weight_max');
$color = $request->query->get('color');
$size = $request->query->get('size');
$material = $request->query->get('material');
$condition = $request->query->get('condition');
$availability = $request->query->get('availability');
// Construire la requête pour les produits
$qb = $em->getRepository(Product::class)->createQueryBuilder('p')
->where('p.isActive = :active')
->setParameter('active', true);
// Filtre par recherche textuelle
if ($searchQuery) {
$qb->andWhere('(LOWER(p.name) LIKE :searchQuery OR LOWER(p.description) LIKE :searchQuery OR LOWER(p.sku) LIKE :searchQuery)')
->setParameter('searchQuery', '%' . mb_strtolower($searchQuery) . '%');
}
// Filtre par catégorie
if ($categorySlug) {
$qb->andWhere('p.category = :category')
->setParameter('category', $em->getRepository(Category::class)->findOneBy(['slug' => $categorySlug]));
}
// Filtre par marque
if ($brandSlug) {
if ($brandSlug === 'non-specifie') {
$qb->andWhere('p.brand IS NULL');
} else {
$qb->andWhere('p.brand = :brand')
->setParameter('brand', $em->getRepository(Brand::class)->findOneBy(['slug' => $brandSlug]));
}
}
// Filtre par boutique
if ($shopSlug) {
$qb->andWhere('p.shop = :shop')
->setParameter('shop', $em->getRepository(Shop::class)->findOneBy(['slug' => $shopSlug]));
}
// Filtre par prix
if ($priceMin) {
$qb->andWhere('p.price >= :priceMin')
->setParameter('priceMin', $priceMin);
}
if ($priceMax) {
$qb->andWhere('p.price <= :priceMax')
->setParameter('priceMax', $priceMax);
}
// Filtre par produit vedette
if ($isFeatured !== null) {
$qb->andWhere('p.isFeatured = :featured')
->setParameter('featured', $isFeatured === 'true' || $isFeatured === '1');
}
// Filtre par produit numérique
if ($isDigital !== null) {
$qb->andWhere('p.isDigital = :digital')
->setParameter('digital', $isDigital === 'true' || $isDigital === '1');
}
// Filtre par statut de stock
if ($stockStatus) {
$qb->andWhere('p.stockStatus = :stockStatus')
->setParameter('stockStatus', $stockStatus);
}
// Filtre par note minimale
if ($ratingMin) {
$qb->andWhere('p.averageRating >= :ratingMin')
->setParameter('ratingMin', $ratingMin);
}
// Filtre par poids
if ($weightMin) {
$qb->andWhere('p.weight >= :weightMin')
->setParameter('weightMin', $weightMin);
}
if ($weightMax) {
$qb->andWhere('p.weight <= :weightMax')
->setParameter('weightMax', $weightMax);
}
// Filtres par attributs (stockés en JSON)
if ($color) {
$qb->andWhere('JSON_CONTAINS(p.attributes, :color) = 1')
->setParameter('color', json_encode(['color' => $color]));
}
if ($size) {
$qb->andWhere('JSON_CONTAINS(p.attributes, :size) = 1')
->setParameter('size', json_encode(['size' => $size]));
}
if ($material) {
$qb->andWhere('JSON_CONTAINS(p.attributes, :material) = 1')
->setParameter('material', json_encode(['material' => $material]));
}
if ($condition) {
$qb->andWhere('JSON_CONTAINS(p.attributes, :condition) = 1')
->setParameter('condition', json_encode(['condition' => $condition]));
}
// Filtre par disponibilité
if ($availability === 'in_stock') {
$qb->andWhere('p.stock > 0');
} elseif ($availability === 'out_of_stock') {
$qb->andWhere('p.stock = 0');
} elseif ($availability === 'low_stock') {
$qb->andWhere('p.stock > 0 AND p.stock <= p.minStockAlert');
}
// Tri
switch ($sortBy) {
case 'price_asc':
$qb->orderBy('p.price', 'ASC');
break;
case 'price_desc':
$qb->orderBy('p.price', 'DESC');
break;
case 'name_asc':
$qb->orderBy('p.name', 'ASC');
break;
case 'name_desc':
$qb->orderBy('p.name', 'DESC');
break;
case 'popular':
$qb->orderBy('p.viewCount', 'DESC');
break;
case 'rank':
case 'best_seller':
// Trier par ranking Amazon (si catégorie sélectionnée)
if ($categorySlug) {
$category = $em->getRepository(Category::class)->findOneBy(['slug' => $categorySlug]);
if ($category) {
$qb->leftJoin('App\Entity\ProductRanking', 'pr', 'WITH',
'pr.product = p AND pr.category = :rankingCategory')
->setParameter('rankingCategory', $category)
->orderBy('pr.rank', 'ASC')
->addOrderBy('pr.score', 'DESC');
break;
}
}
// Fallback: tri par score global
$qb->orderBy('p.salesCount', 'DESC')
->addOrderBy('p.viewCount', 'DESC')
->addOrderBy('p.averageRating', 'DESC');
break;
case 'rating':
$qb->orderBy('p.averageRating', 'DESC');
break;
case 'sales':
$qb->orderBy('p.salesCount', 'DESC');
break;
case 'weight_asc':
$qb->orderBy('p.weight', 'ASC');
break;
case 'weight_desc':
$qb->orderBy('p.weight', 'DESC');
break;
default:
$qb->orderBy('p.publishedAt', 'DESC');
}
// Pagination (9 par page, comme le listing et les boutiques)
$productsPerPage = 9;
$offset = ($page - 1) * $productsPerPage;
$qb->setFirstResult($offset)
->setMaxResults($productsPerPage);
$products = $qb->getQuery()->getResult();
// Compter le total pour la pagination
$totalQuery = clone $qb;
$totalQuery->setFirstResult(0)->setMaxResults(null);
$totalProducts = count($totalQuery->getQuery()->getResult());
$totalPages = ceil($totalProducts / $productsPerPage);
// Récupérer TOUTES les marques disponibles (pas seulement celles de la catégorie)
$availableBrands = [];
// Si une catégorie est sélectionnée, filtrer par catégorie, sinon toutes les marques
if ($categorySlug) {
$category = $em->getRepository(Category::class)->findOneBy(['slug' => $categorySlug]);
if ($category) {
$brands = $em->getRepository(Brand::class)->findBrandsByCategory($categorySlug);
foreach ($brands as $brandData) {
$brand = $brandData[0];
$availableBrands[] = [
'id' => $brand->getId(),
'name' => $brand->getName(),
'slug' => $brand->getSlug(),
'productCount' => $brandData['productCount']
];
}
// Ajouter l'option "Non spécifié" pour les produits sans marque
$productsWithoutBrand = $em->getRepository(Product::class)->createQueryBuilder('p')
->where('p.category = :category')
->andWhere('p.isActive = :active')
->andWhere('p.brand IS NULL')
->setParameter('category', $category)
->setParameter('active', true)
->select('COUNT(p.id)')
->getQuery()
->getSingleScalarResult();
if ($productsWithoutBrand > 0) {
$availableBrands[] = [
'id' => null,
'name' => 'Non spécifié',
'slug' => 'non-specifie',
'productCount' => $productsWithoutBrand
];
}
}
} else {
// Si pas de catégorie, retourner toutes les marques actives
$allBrands = $em->getRepository(Brand::class)->findBy(['isActive' => true], ['name' => 'ASC']);
foreach ($allBrands as $brand) {
$availableBrands[] = [
'id' => $brand->getId(),
'name' => $brand->getName(),
'slug' => $brand->getSlug(),
'productCount' => $brand->getActiveProductsCount()
];
}
}
// Récupérer TOUTES les conditions disponibles
$availableConditions = [];
// Si une catégorie est sélectionnée, filtrer par catégorie, sinon toutes les conditions
if ($categorySlug) {
$category = $em->getRepository(Category::class)->findOneBy(['slug' => $categorySlug]);
if ($category) {
$conditions = $em->getRepository(ProductCondition::class)->createQueryBuilder('cond')
->select('cond, COUNT(p.id) as productCount')
->leftJoin('cond.products', 'p')
->leftJoin('p.category', 'c')
->where('cond.isActive = :active')
->andWhere('c.slug = :categorySlug')
->setParameter('active', true)
->setParameter('categorySlug', $categorySlug)
->groupBy('cond.id')
->orderBy('cond.name', 'ASC')
->getQuery()
->getResult();
foreach ($conditions as $conditionData) {
$condition = $conditionData[0];
$availableConditions[] = [
'id' => $condition->getId(),
'name' => $condition->getName(),
'slug' => $condition->getSlug(),
'productCount' => $conditionData['productCount']
];
}
// Ajouter l'option "Non spécifié" pour les produits sans condition
$productsWithoutCondition = $em->getRepository(Product::class)->createQueryBuilder('p')
->where('p.category = :category')
->andWhere('p.isActive = :active')
->andWhere('p.condition IS NULL')
->setParameter('category', $category)
->setParameter('active', true)
->select('COUNT(p.id)')
->getQuery()
->getSingleScalarResult();
if ($productsWithoutCondition > 0) {
$availableConditions[] = [
'id' => null,
'name' => 'Non spécifié',
'slug' => 'non-specifie',
'productCount' => $productsWithoutCondition
];
}
}
} else {
// Si pas de catégorie, retourner toutes les conditions actives
$allConditions = $em->getRepository(ProductCondition::class)->findBy(['isActive' => true], ['name' => 'ASC']);
foreach ($allConditions as $condition) {
$availableConditions[] = [
'id' => $condition->getId(),
'name' => $condition->getName(),
'slug' => $condition->getSlug(),
'productCount' => $condition->getActiveProductsCount()
];
}
}
// Récupérer TOUTES les boutiques disponibles (pas seulement celles de la catégorie)
$availableShops = [];
if ($categorySlug) {
$shops = $em->getRepository(Shop::class)->createQueryBuilder('s')
->select('s, COUNT(p.id) as productCount')
->leftJoin('s.products', 'p')
->leftJoin('p.category', 'c')
->where('s.isActive = :active')
->andWhere('c.slug = :categorySlug')
->setParameter('active', true)
->setParameter('categorySlug', $categorySlug)
->groupBy('s.id')
->having('productCount > 0')
->orderBy('s.name', 'ASC')
->getQuery()
->getResult();
foreach ($shops as $shopData) {
$shop = $shopData[0];
$availableShops[] = [
'id' => $shop->getId(),
'name' => $shop->getName(),
'slug' => $shop->getSlug(),
'productCount' => $shopData['productCount']
];
}
} else {
// Si pas de catégorie, retourner toutes les boutiques actives
$allShops = $em->getRepository(Shop::class)->createQueryBuilder('s')
->select('s, COUNT(p.id) as productCount')
->leftJoin('s.products', 'p')
->where('s.isActive = :active')
->andWhere('p.isActive = :activeProduct')
->setParameter('active', true)
->setParameter('activeProduct', true)
->groupBy('s.id')
->having('productCount > 0')
->orderBy('s.name', 'ASC')
->getQuery()
->getResult();
foreach ($allShops as $shopData) {
$shop = $shopData[0];
$availableShops[] = [
'id' => $shop->getId(),
'name' => $shop->getName(),
'slug' => $shop->getSlug(),
'productCount' => $shopData['productCount']
];
}
}
// Récupérer les attributs disponibles (couleurs, tailles, etc.)
$availableAttributes = $this->getAvailableAttributes($em, $categorySlug, $brandSlug);
return $this->json([
'success' => true,
'products' => $this->renderView('home/_products_list.html.twig', [
'products' => $products
]),
'pagination' => [
'currentPage' => $page,
'totalPages' => $totalPages,
'totalProducts' => $totalProducts
],
'availableBrands' => $availableBrands,
'availableConditions' => $availableConditions,
'availableShops' => $availableShops,
'availableAttributes' => $availableAttributes
]);
}
/**
* Récupère les attributs disponibles pour les filtres
*/
private function getAvailableAttributes(EntityManagerInterface $em, ?string $categorySlug, ?string $brandSlug): array
{
$qb = $em->getRepository(Product::class)->createQueryBuilder('p')
->where('p.isActive = :active')
->andWhere('p.attributes IS NOT NULL')
->setParameter('active', true);
if ($categorySlug) {
$qb->andWhere('p.category = :category')
->setParameter('category', $em->getRepository(Category::class)->findOneBy(['slug' => $categorySlug]));
}
if ($brandSlug) {
if ($brandSlug === 'non-specifie') {
$qb->andWhere('p.brand IS NULL');
} else {
$qb->andWhere('p.brand = :brand')
->setParameter('brand', $em->getRepository(Brand::class)->findOneBy(['slug' => $brandSlug]));
}
}
$products = $qb->getQuery()->getResult();
// Récupérer la catégorie pour déterminer quels filtres sont pertinents
$category = null;
if ($categorySlug) {
$category = $em->getRepository(Category::class)->findOneBy(['slug' => $categorySlug]);
}
$attributes = [
'colors' => [],
'sizes' => [],
'materials' => [],
'conditions' => []
];
foreach ($products as $product) {
$productAttributes = $product->getAttributes();
if ($productAttributes) {
// Couleurs
if (isset($productAttributes['color'])) {
$color = $productAttributes['color'];
if (!isset($attributes['colors'][$color])) {
$attributes['colors'][$color] = 0;
}
$attributes['colors'][$color]++;
}
// Tailles
if (isset($productAttributes['size'])) {
$size = $productAttributes['size'];
if (!isset($attributes['sizes'][$size])) {
$attributes['sizes'][$size] = 0;
}
$attributes['sizes'][$size]++;
}
// Matériaux
if (isset($productAttributes['material'])) {
$material = $productAttributes['material'];
if (!isset($attributes['materials'][$material])) {
$attributes['materials'][$material] = 0;
}
$attributes['materials'][$material]++;
}
// Conditions
if (isset($productAttributes['condition'])) {
$condition = $productAttributes['condition'];
if (!isset($attributes['conditions'][$condition])) {
$attributes['conditions'][$condition] = 0;
}
$attributes['conditions'][$condition]++;
}
}
}
// Déterminer quels filtres sont pertinents selon la catégorie
$categoryName = $category ? strtolower($category->getName()) : '';
$relevantFilters = [
'colors' => true,
'sizes' => true,
'materials' => true,
'conditions' => true
];
// Catégories où certains filtres ne sont pas pertinents
$alimentaryKeywords = ['aliment', 'food', 'nourriture', 'boisson', 'drink', 'repas', 'meal'];
$hasAlimentaryKeyword = false;
foreach ($alimentaryKeywords as $keyword) {
if (strpos($categoryName, $keyword) !== false) {
$hasAlimentaryKeyword = true;
break;
}
}
if ($hasAlimentaryKeyword) {
// Pour les produits alimentaires : pas de couleur, pas de taille standard
$relevantFilters['colors'] = false;
$relevantFilters['sizes'] = false; // Sauf si volume/poids
}
// Convertir en format array simple et filtrer selon la pertinence
$result = [];
foreach ($attributes as $type => $values) {
if (!$relevantFilters[$type]) {
$result[$type] = []; // Vide si non pertinent
continue;
}
$result[$type] = [];
foreach ($values as $value => $count) {
$result[$type][] = [
'value' => $value,
'count' => $count
];
}
// Trier par nombre de produits décroissant
usort($result[$type], function($a, $b) {
return $b['count'] - $a['count'];
});
}
// Ajouter metadata sur la pertinence des filtres
$result['_metadata'] = [
'relevantFilters' => $relevantFilters,
'categoryName' => $category ? $category->getName() : null
];
return $result;
}
#[Route('/api/brands', name: 'api_brands', methods: ['GET'])]
public function getBrands(Request $request, EntityManagerInterface $em): JsonResponse
{
$categorySlug = $request->query->get('category');
$search = $request->query->get('search');
$limit = $request->query->getInt('limit', 20);
if ($search) {
$brands = $em->getRepository(Brand::class)->createQueryBuilder('b')
->where('b.isActive = :active')
->andWhere('b.name LIKE :search')
->setParameter('active', true)
->setParameter('search', '%' . $search . '%')
->setMaxResults($limit)
->getQuery()
->getResult();
} elseif ($categorySlug) {
$category = $em->getRepository(Category::class)->findOneBy(['slug' => $categorySlug]);
if ($category) {
$brands = $em->getRepository(Brand::class)->createQueryBuilder('b')
->leftJoin('b.products', 'p')
->where('b.isActive = :active')
->andWhere('p.category = :category')
->setParameter('active', true)
->setParameter('category', $category)
->setMaxResults($limit)
->getQuery()
->getResult();
} else {
$brands = [];
}
} else {
$brands = $em->getRepository(Brand::class)->findBy(['isActive' => true], ['name' => 'ASC'], $limit);
}
$brandsData = [];
foreach ($brands as $brand) {
$brandsData[] = [
'id' => $brand->getId(),
'name' => $brand->getName(),
'slug' => $brand->getSlug(),
'description' => $brand->getDescription(),
'logo' => $brand->getLogo(),
'website' => $brand->getWebsite(),
'productCount' => $brand->getActiveProductsCount(),
];
}
return $this->json([
'success' => true,
'brands' => $brandsData,
]);
}
#[Route('/listing/{slug}', name: 'product_show')]
public function productShow(string $slug, EntityManagerInterface $em, ViewTrackingService $viewTrackingService, Request $request): Response
{
$product = $em->getRepository(Product::class)->findOneBy(['slug' => $slug]);
$user = $this->getUser();
$viewTrackingService->trackProductView($product, $user instanceof User ? $user : null);
// Récupérer les statistiques du produit
$productStats = $viewTrackingService->getProductViewStats($product);
// Vérifier si l'utilisateur arrive via un lien d'affiliation
$session = $request->getSession();
$dropshipReferral = null;
if ($session->has('dropship_referral')) {
$referralData = $session->get('dropship_referral');
// Vérifier que c'est bien pour ce produit et que le timestamp est récent (moins de 5 minutes)
if ($referralData['productId'] === $product->getId() && (time() - $referralData['timestamp']) < 300) {
// Ne pas afficher le modal si l'utilisateur est l'affilié lui-même
$currentUser = $this->getUser();
$isOwnLink = $currentUser && isset($referralData['affiliateId']) &&
$currentUser->getId() === $referralData['affiliateId'];
if (!$isOwnLink) {
$dropshipReferral = $referralData;
} else {
// Nettoyer la session si c'est le propre lien de l'utilisateur
$session->remove('dropship_referral');
}
} else {
// Nettoyer la session si ce n'est pas pour ce produit ou si c'est trop ancien
$session->remove('dropship_referral');
}
}
$youMightAlsoLike = $this->recommendationService->getYouMightAlsoLike(
$user instanceof User ? $user : null,
$product,
8
);
return $this->render('home/single-product.html.twig', [
'current_menu' => 'listing',
'product' => $product,
'productStats' => $productStats,
'dropshipReferral' => $dropshipReferral,
'youMightAlsoLike' => $youMightAlsoLike,
]);
}
#[Route('/blog', name: 'blog')]
public function blog(): Response
{
return $this->render('home/blog.html.twig', [
'current_menu' => 'blog',
]);
}
#[Route('/contact', name: 'contact')]
public function contact(): Response
{
return $this->render('home/contact.html.twig', [
'current_menu' => 'contact',
]);
}
#[Route('/help', name: 'help')]
public function help(): Response
{
return $this->render('home/help.html.twig', [
'current_menu' => 'help',
]);
}
#[Route('/faq', name: 'faq')]
public function faq(): Response
{
return $this->render('home/faq.html.twig', [
'current_menu' => 'faq',
]);
}
#[Route('/privacy', name: 'privacy')]
public function privacy(): Response
{
return $this->render('home/privacy.html.twig', [
'current_menu' => 'privacy',
]);
}
#[Route('/terms', name: 'terms')]
public function terms(): Response
{
return $this->render('home/terms.html.twig', [
'current_menu' => 'terms',
]);
}
#[Route('/conditions-generales-vente', name: 'terms_of_sale')]
public function termsOfSale(): Response
{
return $this->render('home/terms_of_sale.html.twig', [
'current_menu' => 'terms',
]);
}
#[Route('/cart/add', name: 'cart_add', methods: ['POST'])]
public function cartAdd(Request $request, EntityManagerInterface $em): JsonResponse
{
$productId = (int) $request->request->get('productId');
$qty = max(1, (int) $request->request->get('qty', 1));
$product = $em->getRepository(Product::class)->find($productId);
if (!$product) return new JsonResponse(['ok' => false, 'message' => 'Produit introuvable'], 404);
$user = $this->getUser();
$session = $request->getSession();
$sessionId = $session->getId();
// Chercher le panier existant
$cart = null;
if ($user) {
// Utilisateur connecté - chercher son panier actif
$cart = $em->getRepository(Cart::class)->findOneBy([
'user' => $user,
'isActive' => true
]);
} else {
// Utilisateur non connecté - chercher par session
$cart = $em->getRepository(Cart::class)->findOneBy([
'sessionId' => $sessionId,
'isActive' => true,
'user' => null
]);
}
if (!$cart) {
// Créer un nouveau panier
$cart = new Cart();
if ($user) {
$cart->setUser($user);
} else {
$cart->setSessionId($sessionId);
}
$em->persist($cart);
}
// Vérifier si le produit existe déjà dans le panier
$existingItem = $cart->getItemByProduct($product);
if ($existingItem) {
$existingItem->incrementQuantity($qty);
// Mettre à jour les totaux du panier après incrémentation
$cart->updateTotals();
} else {
$cartItem = new CartItem();
$cartItem->setProduct($product);
$cartItem->setQuantity($qty);
$cart->addItem($cartItem);
$em->persist($cartItem);
}
$em->flush();
// Gérer la conversion d'affiliation si présente
$session = $request->getSession();
if ($session->has('dropship_referral')) {
$referralData = $session->get('dropship_referral');
// Vérifier que c'est bien pour ce produit
if ($referralData['productId'] === $product->getId()) {
// Vérifier que l'utilisateur n'est pas l'affilié lui-même
$currentUser = $this->getUser();
$isOwnLink = $currentUser && isset($referralData['affiliateId']) &&
$currentUser->getId() === $referralData['affiliateId'];
if (!$isOwnLink) {
try {
$this->dropshipService->processConversion(
$referralData['code'],
$product->getPrice() * $qty,
$currentUser
);
// Nettoyer la session après conversion
$session->remove('dropship_referral');
} catch (\Exception $e) {
// Log l'erreur mais ne bloque pas l'ajout au panier
error_log('Erreur lors de la conversion dropship: ' . $e->getMessage());
}
} else {
// Nettoyer la session si c'est le propre lien de l'utilisateur
$session->remove('dropship_referral');
}
}
}
$totalQty = $cart->getItemCount();
// Récupérer les informations du produit ajouté pour le modal (URL absolue pour l'image)
$productImage = null;
if ($product->getImages() && count($product->getImages()) > 0) {
$imagePath = $product->getImages()[0];
if (!empty($imagePath) && is_string($imagePath)) {
$imagePath = trim($imagePath);
$baseUrl = $request->getSchemeAndHttpHost() . $request->getBasePath();
$host = $request->getHttpHost();
// Si le chemin contient déjà le domaine, extraire seulement le chemin relatif
if (str_contains($imagePath, $host)) {
// Extraire la partie après le dernier occurrence du host
$parts = explode($host, $imagePath);
if (count($parts) > 1) {
// Prendre la dernière partie après le host
$imagePath = end($parts);
$imagePath = ltrim($imagePath, '/');
}
}
// Si le chemin commence déjà par http/https, nettoyer et utiliser
if (str_starts_with($imagePath, 'http://') || str_starts_with($imagePath, 'https://')) {
// Nettoyer toute duplication du host dans l'URL
$productImage = preg_replace('#(https?://)' . preg_quote($host, '#') . '/' . preg_quote($host, '#') . '(?=/#', '$1' . $host, $imagePath);
} else {
// Chemin relatif : normaliser et construire l'URL
// Enlever le slash initial s'il existe
$imagePath = ltrim($imagePath, '/');
// Si ça commence par "uploads/", garder tel quel, sinon ajouter "uploads/products/"
if (!str_starts_with($imagePath, 'uploads/')) {
$imagePath = 'uploads/products/' . $imagePath;
}
// Construire l'URL complète
$productImage = $baseUrl . '/' . $imagePath;
}
// Nettoyage final : supprimer toute duplication restante
$productImage = preg_replace('#(https?://)' . preg_quote($host, '#') . '/' . preg_quote($host, '#') . '#', '$1' . $host, $productImage);
}
}
$productData = [
'id' => $product->getId(),
'name' => $product->getName(),
'slug' => $product->getSlug(),
'price' => $product->getPrice(),
'image' => $productImage,
'shop' => $product->getShop() ? [
'name' => $product->getShop()->getName(),
'slug' => $product->getShop()->getSlug()
] : null
];
// Récupérer des produits suggérés (produits de la même catégorie ou boutique)
$suggestedProducts = [];
$category = $product->getCategory();
$shop = $product->getShop();
if ($category) {
// Produits de la même catégorie, mais pas le produit actuel
$categoryProducts = $em->getRepository(Product::class)->createQueryBuilder('p')
->where('p.category = :category')
->andWhere('p.id != :currentProduct')
->andWhere('p.isActive = true')
->setParameter('category', $category)
->setParameter('currentProduct', $product->getId())
->orderBy('p.publishedAt', 'DESC')
->setMaxResults(4)
->getQuery()
->getResult();
foreach ($categoryProducts as $suggestedProduct) {
$suggestedImage = null;
if ($suggestedProduct->getImages() && count($suggestedProduct->getImages()) > 0) {
$imagePath = $suggestedProduct->getImages()[0];
// S'assurer que le chemin est relatif à la racine (commence par /)
if (!empty($imagePath)) {
if (!str_starts_with($imagePath, '/') && !str_starts_with($imagePath, 'http')) {
if (str_starts_with($imagePath, 'uploads/products/')) {
$suggestedImage = '/' . $imagePath;
} else {
$suggestedImage = '/uploads/products/' . $imagePath;
}
} else {
$suggestedImage = $imagePath;
}
}
}
$suggestedProducts[] = [
'id' => $suggestedProduct->getId(),
'name' => $suggestedProduct->getName(),
'slug' => $suggestedProduct->getSlug(),
'url' => $this->generateUrl('ui_product_show', ['slug' => $suggestedProduct->getSlug()]),
'price' => $suggestedProduct->getPrice(),
'image' => $suggestedImage,
'shop' => $suggestedProduct->getShop() ? $suggestedProduct->getShop()->getName() : null
];
}
}
// Si pas assez de suggestions de catégorie, ajouter des produits populaires de la boutique
if (count($suggestedProducts) < 4 && $shop) {
$shopProducts = $em->getRepository(Product::class)->createQueryBuilder('p')
->where('p.shop = :shop')
->andWhere('p.id != :currentProduct')
->andWhere('p.isActive = true')
->setParameter('shop', $shop)
->setParameter('currentProduct', $product->getId())
->orderBy('p.viewCount', 'DESC')
->setMaxResults(4 - count($suggestedProducts))
->getQuery()
->getResult();
foreach ($shopProducts as $suggestedProduct) {
// Éviter les doublons
$alreadySuggested = false;
foreach ($suggestedProducts as $existing) {
if ($existing['id'] === $suggestedProduct->getId()) {
$alreadySuggested = true;
break;
}
}
if (!$alreadySuggested) {
$suggestedImage = null;
if ($suggestedProduct->getImages() && count($suggestedProduct->getImages()) > 0) {
$imagePath = $suggestedProduct->getImages()[0];
// S'assurer que le chemin est relatif à la racine (commence par /)
if (!empty($imagePath)) {
if (!str_starts_with($imagePath, '/') && !str_starts_with($imagePath, 'http')) {
if (str_starts_with($imagePath, 'uploads/products/')) {
$suggestedImage = '/' . $imagePath;
} else {
$suggestedImage = '/uploads/products/' . $imagePath;
}
} else {
$suggestedImage = $imagePath;
}
}
}
$suggestedProducts[] = [
'id' => $suggestedProduct->getId(),
'name' => $suggestedProduct->getName(),
'slug' => $suggestedProduct->getSlug(),
'url' => $this->generateUrl('ui_product_show', ['slug' => $suggestedProduct->getSlug()]),
'price' => $suggestedProduct->getPrice(),
'image' => $suggestedImage,
'shop' => $suggestedProduct->getShop() ? $suggestedProduct->getShop()->getName() : null
];
}
}
}
// Limiter à 4 suggestions maximum
$suggestedProducts = array_slice($suggestedProducts, 0, 4);
return new JsonResponse([
'ok' => true,
'totalQty' => $totalQty,
'product' => $productData,
'suggestedProducts' => $suggestedProducts
]);
}
#[Route('/cart/update', name: 'cart_update', methods: ['POST'])]
public function cartUpdate(Request $request, EntityManagerInterface $em): JsonResponse
{
$productId = (int) $request->request->get('productId');
$qty = max(0, (int) $request->request->get('qty', 1));
$user = $this->getUser();
$session = $request->getSession();
$sessionId = $session->getId();
error_log("CartUpdate - User: " . ($user ? $user->getId() : 'null') . ", Session: $sessionId");
error_log("CartUpdate - Product: $productId, Qty: $qty");
// Trouver le panier
$cart = null;
if ($user) {
$cart = $em->getRepository(Cart::class)->findOneBy([
'user' => $user,
'isActive' => true
]);
error_log("CartUpdate - Looking for user cart");
} else {
$cart = $em->getRepository(Cart::class)->findOneBy([
'sessionId' => $sessionId,
'isActive' => true,
'user' => null
]);
error_log("CartUpdate - Looking for session cart");
}
if (!$cart) {
error_log("CartUpdate - Cart not found!");
return new JsonResponse(['ok' => false, 'message' => 'Panier non trouvé'], 404);
}
error_log("CartUpdate - Cart found: " . $cart->getId());
$product = $em->getRepository(Product::class)->find($productId);
if (!$product) {
return new JsonResponse(['ok' => false, 'message' => 'Produit non trouvé'], 404);
}
if ($qty === 0) {
// Supprimer l'article du panier
$item = $cart->getItemByProduct($product);
if ($item) {
$cart->removeItem($item);
$em->remove($item);
}
} else {
// Mettre à jour ou ajouter l'article
$existingItem = $cart->getItemByProduct($product);
if ($existingItem) {
$existingItem->setQuantity($qty);
// Mettre à jour les totaux du panier après changement de quantité
$cart->updateTotals();
} else {
$cartItem = new CartItem();
$cartItem->setProduct($product);
$cartItem->setQuantity($qty);
$cart->addItem($cartItem);
$em->persist($cartItem);
}
}
$em->flush();
// Utiliser les valeurs mises à jour par updateTotals()
$subtotal = $cart->getTotalAmount();
$totalQty = $cart->getItemCount();
// S'assurer que les valeurs sont numériques
$subtotal = $subtotal !== null ? (float) $subtotal : 0.0;
$totalQty = $totalQty !== null ? (int) $totalQty : 0;
// Logs de débogage
error_log("CartUpdate - Product: $productId, Qty: $qty");
error_log("CartUpdate - Subtotal: $subtotal, TotalQty: $totalQty");
error_log("CartUpdate - Cart ID: " . $cart->getId());
$response = [
'ok' => true,
'subtotal' => $subtotal,
'totalQty' => $totalQty
];
error_log("CartUpdate - Response: " . json_encode($response));
return new JsonResponse($response);
}
#[Route('/cart', name: 'cart')]
public function cart(Request $request, EntityManagerInterface $em): Response
{
$user = $this->getUser();
$session = $request->getSession();
$sessionId = $session->getId();
// Récupérer le panier depuis la base de données
$cart = null;
if ($user) {
$cart = $em->getRepository(Cart::class)->findOneBy([
'user' => $user,
'isActive' => true
]);
} else {
$cart = $em->getRepository(Cart::class)->findOneBy([
'sessionId' => $sessionId,
'isActive' => true,
'user' => null
]);
}
$items = [];
$subtotal = 0.0;
if ($cart) {
$cartItems = $cart->getItems();
foreach ($cartItems as $cartItem) {
$product = $cartItem->getProduct();
$image = ($product && $product->getImages() && count($product->getImages()) > 0)
? $product->getImages()[0]
: null;
$items[] = [
'id' => $product ? $product->getId() : 0,
'name' => $product ? $product->getName() : 'Produit inconnu',
'price' => $cartItem->getUnitPrice(),
'qty' => $cartItem->getQuantity(),
'image' => $image,
'slug' => $product ? $product->getSlug() : '',
'cartItem' => $cartItem
];
}
$subtotal = (float) $cart->getTotalAmount();
}
return $this->render('home/cart.html.twig', [
'current_menu' => 'cart',
'items' => $items,
'subtotal' => $subtotal,
]);
}
#[Route('/cart/remove', name: 'cart_remove', methods: ['POST'])]
public function cartRemove(Request $request, EntityManagerInterface $em): JsonResponse
{
$productId = (int) $request->request->get('productId');
$user = $this->getUser();
$session = $request->getSession();
$sessionId = $session->getId();
// Trouver le panier
$cart = null;
if ($user) {
$cart = $em->getRepository(Cart::class)->findOneBy([
'user' => $user,
'isActive' => true
]);
} else {
$cart = $em->getRepository(Cart::class)->findOneBy([
'sessionId' => $sessionId,
'isActive' => true,
'user' => null
]);
}
if (!$cart) {
return new JsonResponse(['ok' => false, 'message' => 'Panier non trouvé'], 404);
}
$product = $em->getRepository(Product::class)->find($productId);
if (!$product) {
return new JsonResponse(['ok' => false, 'message' => 'Produit non trouvé'], 404);
}
// Trouver et supprimer l'article du panier
$item = $cart->getItemByProduct($product);
if ($item) {
$cart->removeItem($item);
$em->remove($item);
$em->flush();
// Après flush, récupérer les valeurs mises à jour
$subtotal = (float) $cart->getTotalAmount();
$totalQty = $cart->getItemCount();
return new JsonResponse([
'ok' => true,
'subtotal' => $subtotal,
'totalQty' => $totalQty
]);
}
return new JsonResponse(['ok' => false, 'message' => 'Article non trouvé dans le panier'], 404);
}
#[Route('/checkout', name: 'checkout')]
public function checkout(EntityManagerInterface $em): Response
{
$user = $this->getUser();
$session = $this->container->get('request_stack')->getSession();
$sessionId = $session->getId();
// Récupérer le panier depuis la base de données
$cart = null;
if ($user) {
$cart = $em->getRepository(Cart::class)->findOneBy([
'user' => $user,
'isActive' => true
]);
} else {
$cart = $em->getRepository(Cart::class)->findOneBy([
'sessionId' => $sessionId,
'isActive' => true,
'user' => null
]);
}
if (!$cart || $cart->getItems()->isEmpty()) {
return $this->redirectToRoute('ui_cart');
}
// Récupérer les items du panier avec les détails des produits
$items = [];
$subtotal = 0.0;
foreach ($cart->getItems() as $cartItem) {
$product = $cartItem->getProduct();
$image = ($product && $product->getImages() && count($product->getImages()) > 0)
? $product->getImages()[0]
: null;
$items[] = [
'id' => $product ? $product->getId() : 0,
'name' => $product ? $product->getName() : 'Produit inconnu',
'price' => (float) $cartItem->getUnitPrice(),
'qty' => $cartItem->getQuantity(),
'image' => $image,
'slug' => $product ? $product->getSlug() : '',
'total' => (float) $cartItem->getTotalPrice()
];
$subtotal += (float) $cartItem->getTotalPrice();
}
// Récupérer les adresses de l'utilisateur
$addresses = [];
if ($user) {
$addresses = $em->getRepository(Address::class)->findBy(['user' => $user]);
}
// Récupérer les moyens de paiement actifs
$paymentMethods = $em->getRepository(PaymentMethod::class)->findBy(['isActive' => true], ['sortOrder' => 'ASC']);
// Récupérer les moyens de livraison actifs
$shippingMethods = $em->getRepository(ShippingMethod::class)->findBy(['isActive' => true], ['sortOrder' => 'ASC']);
return $this->render('home/checkout.html.twig', [
'current_menu' => 'cart',
'items' => $items,
'subtotal' => $subtotal,
'addresses' => $addresses,
'paymentMethods' => $paymentMethods,
'shippingMethods' => $shippingMethods,
'user' => $user,
'cart' => $cart,
]);
}
#[Route('/checkout/finish-later', name: 'checkout_finish_later', methods: ['POST'])]
public function checkoutFinishLater(Request $request, EntityManagerInterface $em, \App\Service\ShopLimitService $shopLimitService): JsonResponse
{
$user = $this->getUser();
if (!$user) {
return new JsonResponse(['ok' => false, 'message' => 'Vous devez être connecté pour continuer'], 401);
}
$session = $request->getSession();
$cart = $em->getRepository(Cart::class)->findOneBy([
'user' => $user,
'isActive' => true
]);
if (!$cart || $cart->getItems()->isEmpty()) {
return new JsonResponse(['ok' => false, 'message' => 'Panier vide ou introuvable'], 400);
}
$data = json_decode($request->getContent(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
$data = $request->request->all();
}
$shippingMethodId = $data['shippingMethod'] ?? null;
$deliveryAddressId = $data['deliveryAddress'] ?? null;
$orderNotes = $data['orderNotes'] ?? '';
$giftCardCode = $data['giftCardCode'] ?? null;
$giftCardDiscount = (float) ($data['giftCardDiscount'] ?? 0);
$subtotal = 0.0;
foreach ($cart->getItems() as $cartItem) {
$subtotal += (float) $cartItem->getTotalPrice();
}
// Vérifier les limites des boutiques avant de créer la commande
$shopsInCart = [];
$shopAmounts = [];
foreach ($cart->getItems() as $cartItem) {
$shop = $cartItem->getProduct()->getShop();
if ($shop) {
$shopId = $shop->getId();
if (!isset($shopsInCart[$shopId])) {
$shopsInCart[$shopId] = $shop;
$shopAmounts[$shopId] = 0;
}
$shopAmounts[$shopId] += (float) $cartItem->getTotalPrice();
}
}
// Vérifier les limites pour chaque boutique
foreach ($shopsInCart as $shopId => $shop) {
// Vérifier la limite de commandes
$orderLimitCheck = $shopLimitService->canShopReceiveOrder($shop);
if (!$orderLimitCheck['allowed']) {
return new JsonResponse([
'ok' => false,
'message' => sprintf(
'Impossible de finaliser la commande. La boutique "%s" a atteint sa limite de commandes. %s',
$shop->getName(),
$orderLimitCheck['message']
)
], 403);
}
// Vérifier la limite de CA
$caLimitCheck = $shopLimitService->canShopExceedCaLimit($shop, $shopAmounts[$shopId]);
if (!$caLimitCheck['allowed']) {
return new JsonResponse([
'ok' => false,
'message' => sprintf(
'Impossible de finaliser la commande. Cette commande dépasserait la limite de CA de la boutique "%s". %s',
$shop->getName(),
$caLimitCheck['message']
)
], 403);
}
}
$shippingMethod = $shippingMethodId ? $em->getRepository(ShippingMethod::class)->find($shippingMethodId) : null;
$shippingAmount = $shippingMethod ? (float) $shippingMethod->getPrice() : 0.0;
$totalBeforeTax = $subtotal + $shippingAmount - $giftCardDiscount;
$taxAmount = $totalBeforeTax * 0.01;
$totalAmount = $totalBeforeTax + $taxAmount;
try {
$order = new Order();
$order->setOrderNumber('ORD-' . strtoupper(uniqid()));
$order->setCustomer($user);
$order->setSubtotal((string) $subtotal);
$order->setTaxAmount((string) $taxAmount);
$order->setShippingAmount((string) $shippingAmount);
$order->setDiscountAmount((string) $giftCardDiscount);
$order->setTotalAmount((string) $totalAmount);
$order->setCurrency('HTG');
$order->setStatus('pending');
$order->setPaymentStatus('pending');
$order->setPaymentMethod(null);
$order->setShippingMethod($shippingMethod ? $shippingMethod->getName() : null);
$order->setNotes($orderNotes);
$order->setOrderedAt(new \DateTimeImmutable('now'));
if ($deliveryAddressId) {
$address = $em->getRepository(Address::class)->find($deliveryAddressId);
if ($address) {
$fullAddress = sprintf(
'%s, %s, %s, %s%s',
$address->getStreet(),
$address->getCity(),
$address->getState(),
$address->getCountry(),
$address->getZipCode() ? ' ' . $address->getZipCode() : ''
);
$order->setShippingAddress($fullAddress);
$order->setBillingAddress($fullAddress);
}
}
foreach ($cart->getItems() as $cartItem) {
$orderItem = new \App\Entity\OrderItem();
$orderItem->setOrder($order);
$orderItem->setProduct($cartItem->getProduct());
$orderItem->setQuantity($cartItem->getQuantity());
$orderItem->setUnitPrice((string) $cartItem->getUnitPrice());
$orderItem->setTotalPrice((string) $cartItem->getTotalPrice());
$em->persist($orderItem);
}
if ($giftCardCode && $giftCardDiscount > 0) {
$giftCard = $em->getRepository(\App\Entity\GiftCard::class)->findOneBy(['code' => $giftCardCode]);
if ($giftCard) {
$newBalance = max(0, (float) $giftCard->getBalance() - $giftCardDiscount);
$giftCard->setBalance((string) $newBalance);
$giftCard->setLastUsedAt(new \DateTimeImmutable('now'));
$em->persist($giftCard);
}
}
$cart->setIsActive(false);
$em->persist($order);
$em->flush();
$this->notificationService->createOrderCreatedNotification($user, $order);
$shopsNotified = [];
foreach ($order->getItems() as $orderItem) {
$product = $orderItem->getProduct();
$shop = $product->getShop();
if ($shop && !in_array($shop->getId(), $shopsNotified)) {
$shopOwner = $shop->getManager()->first();
if ($shopOwner) {
$this->notificationService->createOrderReceivedNotification($shopOwner, $order, $shop);
$shopsNotified[] = $shop->getId();
}
// Mettre à jour les compteurs de la boutique
$shopLimitService->updateOrderCount($shop);
$shopLimitService->updateRevenue($shop);
}
}
return new JsonResponse([
'ok' => true,
'message' => 'Commande enregistrée. Vous pourrez la finaliser plus tard.',
'orderId' => $order->getId(),
'orderNumber' => $order->getOrderNumber(),
'redirectUrl' => $this->generateUrl('ui_account_orders')
]);
} catch (\Exception $e) {
return new JsonResponse([
'ok' => false,
'message' => 'Erreur lors de l\'enregistrement de la commande: ' . $e->getMessage()
], 500);
}
}
#[Route('/checkout/process-moncash', name: 'checkout_process_moncash', methods: ['POST', 'GET'])]
public function processMonCashPayment(Request $request, EntityManagerInterface $em): JsonResponse|Response
{
// Si c'est une requête GET avec checkStatus, vérifier le statut
if ($request->isMethod('GET') && $request->query->has('checkStatus')) {
return $this->checkMonCashPaymentStatus($request, $em);
}
// Si c'est une requête GET sans checkStatus, rediriger vers le checkout
if ($request->isMethod('GET')) {
$this->addFlash('info', 'Cette page n\'est accessible que via le processus de paiement.');
return $this->redirectToRoute('ui_checkout');
}
$user = $this->getUser();
if (!$user) {
return new JsonResponse(['ok' => false, 'message' => 'Vous devez être connecté pour effectuer un paiement'], 401);
}
$rawContent = $request->getContent();
error_log('MonCash Request Raw Content: ' . $rawContent);
$data = json_decode($rawContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log('MonCash JSON Decode Error: ' . json_last_error_msg());
return new JsonResponse([
'ok' => false,
'message' => 'Données JSON invalides: ' . json_last_error_msg(),
'raw_content' => substr($rawContent, 0, 200)
], 400);
}
error_log('MonCash Request Data: ' . json_encode($data));
if (!isset($data['amount'])) {
error_log('MonCash Error: Amount missing in request data');
return new JsonResponse([
'ok' => false,
'message' => 'Montant manquant',
'received_data' => array_keys($data ?? [])
], 400);
}
$moncashNumber = isset($data['moncashNumber']) ? preg_replace('/\s+/', '', $data['moncashNumber']) : '';
$moncashHolderName = $data['moncashHolderName'] ?? '';
$amount = (float) $data['amount'];
// Sauvegarder les données de commande dans la session
if (isset($data['orderData'])) {
$session = $request->getSession();
$session->set('moncash_order_data', $data['orderData']);
}
error_log('MonCash: Amount = ' . $amount . ', MonCashNumber = ' . ($moncashNumber ?: 'empty'));
// Validation du numéro MonCash seulement s'il est fourni (format haïtien: 509XXXXXXXX)
if (!empty($moncashNumber) && !preg_match('/^509[0-9]{8}$/', $moncashNumber)) {
return new JsonResponse(['ok' => false, 'message' => 'Numéro MonCash invalide. Format attendu : 509 XX XXX XXX'], 400);
}
// Validation du montant
if ($amount <= 0) {
error_log('MonCash Error: Invalid amount ' . $amount);
return new JsonResponse(['ok' => false, 'message' => 'Montant invalide'], 400);
}
// Récupérer le panier
error_log('MonCash: Looking for cart for user ID: ' . $user->getId());
$cart = $em->getRepository(Cart::class)->findOneBy([
'user' => $user,
'isActive' => true
]);
error_log('MonCash Cart Check - User ID: ' . $user->getId() . ', Cart Found: ' . ($cart ? 'Yes (ID: ' . $cart->getId() . ')' : 'No'));
if (!$cart) {
error_log('MonCash Error: No active cart found for user ' . $user->getId());
return new JsonResponse([
'ok' => false,
'message' => 'Panier vide ou introuvable. Veuillez ajouter des produits à votre panier.',
'user_id' => $user->getId()
], 400);
}
if ($cart->getItems()->isEmpty()) {
error_log('MonCash Error: Cart found but empty for user ' . $user->getId() . ', Cart ID: ' . $cart->getId());
return new JsonResponse([
'ok' => false,
'message' => 'Votre panier est vide. Veuillez ajouter des produits avant de finaliser votre commande.',
'cart_id' => $cart->getId()
], 400);
}
error_log('MonCash Cart Items Count: ' . $cart->getItems()->count());
try {
// Créer une transaction MonCash en attente
$transaction = new \App\Entity\MonCashTransaction();
$transactionId = 'TXN-' . strtoupper(uniqid()) . '-' . time();
$transaction->setTransactionId($transactionId);
$transaction->setUser($user);
$transaction->setAmount((string) $amount);
$transaction->setStatus('pending');
$transaction->setMoncashNumber($moncashNumber);
$transaction->setMoncashHolderName($moncashHolderName);
// Sauvegarder les données de commande en JSON
$orderData = $data['orderData'] ?? [];
$transaction->setOrderData(json_encode($orderData));
$em->persist($transaction);
$em->flush();
// Vérifier les credentials MonCash avant d'appeler le service
$apiKey = getenv('MONCASH_API_KEY') ?: ($_ENV['MONCASH_API_KEY'] ?? '');
$apiSecret = getenv('MONCASH_API_SECRET') ?: ($_ENV['MONCASH_API_SECRET'] ?? '');
error_log('=== MONCASH CREDENTIALS CHECK ===');
error_log('MONCASH_API_KEY: ' . (!empty($apiKey) ? 'Present (' . substr($apiKey, 0, 8) . '...)' : 'MISSING'));
error_log('MONCASH_API_SECRET: ' . (!empty($apiSecret) ? 'Present (' . substr($apiSecret, 0, 8) . '...)' : 'MISSING'));
error_log('getenv(MONCASH_API_KEY): ' . (getenv('MONCASH_API_KEY') ?: 'empty'));
error_log('$_ENV[MONCASH_API_KEY]: ' . ($_ENV['MONCASH_API_KEY'] ?? 'not set'));
// Intégration avec l'API MonCash
// Utiliser le service MonCash pour initier le paiement et obtenir l'URL du portail
error_log('MonCash: Calling initiatePayment with amount: ' . $amount . ', transactionId: ' . $transactionId);
$paymentResult = $this->monCashService->initiatePayment([
'amount' => $amount,
'phone' => $moncashNumber ?: null,
'orderId' => $transactionId, // Utiliser l'ID de transaction comme orderId
'description' => 'Paiement commande MaketOu'
]);
error_log('MonCash initiatePayment result: ' . json_encode($paymentResult));
if (!$paymentResult['success']) {
$errorMessage = $paymentResult['message'] ?? 'Erreur lors de l\'initialisation du paiement MonCash';
// Améliorer le message d'erreur pour l'erreur 401 (credentials invalides)
if (isset($paymentResult['response']) && is_array($paymentResult['response'])) {
$apiResponse = $paymentResult['response'];
if (isset($apiResponse['status']) && $apiResponse['status'] == 401) {
$errorMessage = 'Erreur d\'authentification avec l\'API MonCash (401 Unauthorized). ' .
'Vos credentials MonCash (MONCASH_API_KEY et MONCASH_API_SECRET) sont invalides ou incorrects. ' .
'Vérifiez qu\'ils sont corrects dans votre fichier .env et qu\'il n\'y a pas d\'espaces ou de caractères invisibles.';
}
}
// Logger l'erreur pour le débogage
error_log('MonCash Payment Initiation Failed: ' . $errorMessage);
error_log('Payment Result: ' . json_encode($paymentResult));
error_log('Transaction ID: ' . $transactionId);
error_log('Amount: ' . $amount);
$transaction->setStatus('failed');
$transaction->setErrorMessage($errorMessage);
$em->flush();
// Retourner une réponse détaillée pour le débogage
$errorResponse = [
'ok' => false,
'message' => $errorMessage,
'transactionId' => $transactionId,
'amount' => $amount
];
// Ajouter les détails de l'erreur si disponibles
if (isset($paymentResult['response'])) {
$errorResponse['api_response'] = $paymentResult['response'];
}
return new JsonResponse($errorResponse, 400);
}
// Mettre à jour la transaction avec les informations de paiement
if (isset($paymentResult['paymentId'])) {
$transaction->setPaymentToken($paymentResult['paymentId']);
}
$transaction->setMoncashResponse(json_encode($paymentResult));
$em->flush();
// Retourner l'URL du portail MonCash pour affichage dans le modal
if (isset($paymentResult['portalUrl'])) {
// Convertir l'URL relative en URL absolue si nécessaire
$portalUrl = $paymentResult['portalUrl'];
if (strpos($portalUrl, 'http') !== 0) {
// C'est une URL relative, la convertir en absolue
$portalUrl = $request->getSchemeAndHttpHost() . $portalUrl;
}
return new JsonResponse([
'ok' => true,
'portalUrl' => $portalUrl,
'paymentId' => $paymentResult['paymentId'] ?? null,
'transactionId' => $transactionId,
'simulation' => false
]);
}
return new JsonResponse([
'ok' => false,
'message' => 'Erreur: URL du portail MonCash non reçue'
], 400);
} catch (\Exception $e) {
return new JsonResponse([
'ok' => false,
'message' => 'Erreur lors de l\'initialisation du paiement: ' . $e->getMessage()
], 500);
}
}
/**
* Vérifier le statut d'un paiement MonCash
*/
private function checkMonCashPaymentStatus(Request $request, EntityManagerInterface $em): JsonResponse
{
$transactionId = $request->query->get('checkStatus');
if (!$transactionId) {
return new JsonResponse(['ok' => false, 'message' => 'Transaction ID manquant'], 400);
}
$user = $this->getUser();
if (!$user) {
return new JsonResponse(['ok' => false, 'message' => 'Vous devez être connecté'], 401);
}
$transaction = $em->getRepository(\App\Entity\MonCashTransaction::class)->findOneBy(['transactionId' => $transactionId]);
if (!$transaction) {
return new JsonResponse(['ok' => false, 'message' => 'Transaction non trouvée'], 404);
}
// Vérifier que la transaction appartient à l'utilisateur
if ($transaction->getUser()->getId() !== $user->getId()) {
return new JsonResponse(['ok' => false, 'message' => 'Accès non autorisé'], 403);
}
// Si la transaction est déjà complétée, retourner la commande
if ($transaction->getStatus() === 'completed' && $transaction->getOrder()) {
return new JsonResponse([
'ok' => true,
'orderCreated' => true,
'orderId' => $transaction->getOrder()->getId(),
'orderNumber' => $transaction->getOrder()->getOrderNumber(),
'redirectUrl' => $this->generateUrl('ui_account_orders')
]);
}
// Vérifier le statut avec MonCash
if ($transaction->getPaymentToken()) {
$statusResult = $this->monCashService->checkPaymentStatus($transaction->getPaymentToken());
if ($statusResult['success'] && isset($statusResult['status'])) {
$moncashStatus = strtolower($statusResult['status']);
if (in_array($moncashStatus, ['completed', 'success', 'approved', 'paid'])) {
// Paiement réussi, créer la commande
$cart = $em->getRepository(Cart::class)->findOneBy([
'user' => $transaction->getUser(),
'isActive' => true
]);
if ($cart) {
$result = $this->createOrderFromTransaction($transaction, $em, $cart);
$resultData = json_decode($result->getContent(), true);
return new JsonResponse($resultData);
}
} elseif (in_array($moncashStatus, ['failed', 'cancelled', 'rejected'])) {
$transaction->setStatus('failed');
$transaction->setErrorMessage('Paiement refusé par MonCash');
$em->flush();
}
}
}
return new JsonResponse([
'ok' => true,
'orderCreated' => false,
'status' => $transaction->getStatus()
]);
}
/**
* Créer une commande à partir d'une transaction MonCash
*/
/**
* Incrémente le compteur de ventes d'un produit
*/
private function incrementProductSalesCount(Product $product, int $quantity, EntityManagerInterface $em): void
{
$currentSalesCount = $product->getSalesCount() ?? 0;
$product->setSalesCount($currentSalesCount + $quantity);
$em->persist($product);
}
private function createOrderFromTransaction(\App\Entity\MonCashTransaction $transaction, EntityManagerInterface $em, Cart $cart): JsonResponse
{
try {
$user = $transaction->getUser();
$orderData = json_decode($transaction->getOrderData(), true);
$shippingMethodId = $orderData['shippingMethod'] ?? null;
$deliveryAddressId = $orderData['deliveryAddress'] ?? null;
$orderNotes = $orderData['orderNotes'] ?? '';
$giftCardCode = $orderData['giftCardCode'] ?? null;
$giftCardDiscount = $orderData['giftCardDiscount'] ?? 0;
// Calculer les totaux en additionnant les items du panier
// IMPORTANT: Calculer le sous-total en additionnant les totalPrice de chaque item
// pour garantir la cohérence avec les OrderItems créés
$subtotal = 0.0;
foreach ($cart->getItems() as $cartItem) {
$subtotal += (float) $cartItem->getTotalPrice();
}
$shippingMethod = $shippingMethodId ? $em->getRepository(ShippingMethod::class)->find($shippingMethodId) : null;
$shippingAmount = $shippingMethod ? (float) $shippingMethod->getPrice() : 0.0;
$totalBeforeTax = $subtotal + $shippingAmount - $giftCardDiscount;
$taxAmount = $totalBeforeTax * 0.01;
$totalAmount = $totalBeforeTax + $taxAmount;
// Créer la commande
$order = new Order();
$order->setOrderNumber('ORD-' . strtoupper(uniqid()));
$order->setCustomer($user);
$order->setSubtotal((string) $subtotal);
$order->setTaxAmount((string) $taxAmount);
$order->setShippingAmount((string) $shippingAmount);
$order->setDiscountAmount((string) $giftCardDiscount);
$order->setTotalAmount((string) $totalAmount);
$order->setCurrency('HTG');
$order->setStatus('pending');
$order->setPaymentStatus('paid');
$order->setPaymentMethod('MonCash');
$order->setShippingMethod($shippingMethod ? $shippingMethod->getName() : null);
$order->setNotes($orderNotes);
$order->setOrderedAt(new \DateTimeImmutable('now'));
// Ajouter l'adresse de livraison
if ($deliveryAddressId) {
$address = $em->getRepository(Address::class)->find($deliveryAddressId);
if ($address) {
$fullAddress = sprintf(
'%s, %s, %s, %s%s',
$address->getStreet(),
$address->getCity(),
$address->getState(),
$address->getCountry(),
$address->getZipCode() ? ' ' . $address->getZipCode() : ''
);
$order->setShippingAddress($fullAddress);
$order->setBillingAddress($fullAddress);
}
}
// Ajouter les items de la commande
foreach ($cart->getItems() as $cartItem) {
$orderItem = new \App\Entity\OrderItem();
$orderItem->setOrder($order);
$orderItem->setProduct($cartItem->getProduct());
$orderItem->setQuantity($cartItem->getQuantity());
$orderItem->setUnitPrice((string) $cartItem->getUnitPrice());
$orderItem->setTotalPrice((string) $cartItem->getTotalPrice());
$em->persist($orderItem);
// Incrémenter le compteur de ventes si la commande est payée
if ($order->getPaymentStatus() === 'paid') {
$this->incrementProductSalesCount($cartItem->getProduct(), $cartItem->getQuantity(), $em);
}
}
// Appliquer la carte cadeau si fournie
if ($giftCardCode && $giftCardDiscount > 0) {
$giftCard = $em->getRepository(\App\Entity\GiftCard::class)->findOneBy(['code' => $giftCardCode]);
if ($giftCard) {
$newBalance = max(0, (float) $giftCard->getBalance() - $giftCardDiscount);
$giftCard->setBalance((string) $newBalance);
$giftCard->setLastUsedAt(new \DateTimeImmutable('now'));
$em->persist($giftCard);
}
}
// Désactiver le panier
$cart->setIsActive(false);
// Lier la transaction à la commande
$transaction->setOrder($order);
$transaction->setStatus('completed');
$em->persist($order);
$em->persist($transaction);
$em->flush();
// Envoyer des notifications
// Notification pour le client
$this->notificationService->createOrderCreatedNotification($user, $order);
// Notifications pour les vendeurs (un par boutique)
$shopsNotified = [];
foreach ($order->getItems() as $orderItem) {
$product = $orderItem->getProduct();
$shop = $product->getShop();
if ($shop && !in_array($shop->getId(), $shopsNotified)) {
$shopOwner = $shop->getManager()->first();
if ($shopOwner) {
$this->notificationService->createOrderReceivedNotification($shopOwner, $order, $shop);
$shopsNotified[] = $shop->getId();
}
}
}
return new JsonResponse([
'ok' => true,
'message' => 'Paiement MonCash traité avec succès',
'orderId' => $order->getId(),
'orderNumber' => $order->getOrderNumber(),
'redirectUrl' => $this->generateUrl('ui_account_orders'),
'orderCreated' => true
]);
} catch (\Exception $e) {
$transaction->setStatus('failed');
$transaction->setErrorMessage($e->getMessage());
$em->flush();
return new JsonResponse([
'ok' => false,
'message' => 'Erreur lors de la création de la commande: ' . $e->getMessage()
], 500);
}
}
#[Route('/checkout/moncash-simulation', name: 'checkout_moncash_simulation', methods: ['GET'])]
public function moncashSimulation(Request $request, EntityManagerInterface $em): Response
{
$user = $this->getUser();
if (!$user) {
$this->addFlash('error', 'Vous devez être connecté pour effectuer un paiement.');
return $this->redirectToRoute('ui_app_login');
}
// Récupérer les paramètres de la simulation
$token = $request->query->get('token');
$amount = $request->query->get('amount');
$orderId = $request->query->get('orderId');
$transactionId = $request->query->get('transactionId');
if (!$token || !$amount) {
$this->addFlash('error', 'Paramètres de simulation invalides.');
return $this->redirectToRoute('ui_checkout');
}
// Récupérer les données de la commande depuis la session
$session = $request->getSession();
$orderData = $session->get('moncash_order_data', []);
if (empty($orderData)) {
$this->addFlash('error', 'Données de commande non trouvées. Veuillez recommencer le processus de paiement.');
return $this->redirectToRoute('ui_checkout');
}
try {
// Récupérer le panier
$cart = $em->getRepository(Cart::class)->findOneBy([
'user' => $user,
'isActive' => true
]);
if (!$cart || $cart->getItems()->isEmpty()) {
$this->addFlash('error', 'Panier vide.');
return $this->redirectToRoute('ui_checkout');
}
// Récupérer les données de la commande
$shippingMethodId = $orderData['shippingMethod'] ?? null;
$deliveryAddressId = $orderData['deliveryAddress'] ?? null;
$orderNotes = $orderData['orderNotes'] ?? '';
$giftCardCode = $orderData['giftCardCode'] ?? null;
$giftCardDiscount = $orderData['giftCardDiscount'] ?? 0;
// Calculer les totaux en additionnant les items du panier
// IMPORTANT: Calculer le sous-total en additionnant les totalPrice de chaque item
// pour garantir la cohérence avec les OrderItems créés
$subtotal = 0.0;
foreach ($cart->getItems() as $cartItem) {
$subtotal += (float) $cartItem->getTotalPrice();
}
$shippingMethod = $shippingMethodId ? $em->getRepository(ShippingMethod::class)->find($shippingMethodId) : null;
$shippingAmount = $shippingMethod ? (float) $shippingMethod->getPrice() : 0.0;
$totalBeforeTax = $subtotal + $shippingAmount - $giftCardDiscount;
$taxAmount = $totalBeforeTax * 0.01;
$totalAmount = $totalBeforeTax + $taxAmount;
// Créer la commande
$order = new Order();
$order->setOrderNumber('ORD-' . strtoupper(uniqid()));
$order->setCustomer($user);
$order->setSubtotal((string) $subtotal);
$order->setTaxAmount((string) $taxAmount);
$order->setShippingAmount((string) $shippingAmount);
$order->setDiscountAmount((string) $giftCardDiscount);
$order->setTotalAmount((string) $totalAmount);
$order->setCurrency('HTG');
$order->setStatus('pending');
$order->setPaymentStatus('paid');
$order->setPaymentMethod('MonCash');
$order->setShippingMethod($shippingMethod ? $shippingMethod->getName() : null);
$order->setNotes($orderNotes);
$order->setOrderedAt(new \DateTimeImmutable('now'));
// Ajouter l'adresse de livraison
if ($deliveryAddressId) {
$address = $em->getRepository(Address::class)->find($deliveryAddressId);
if ($address) {
$fullAddress = sprintf(
'%s, %s, %s, %s%s',
$address->getStreet(),
$address->getCity(),
$address->getState(),
$address->getCountry(),
$address->getZipCode() ? ' ' . $address->getZipCode() : ''
);
$order->setShippingAddress($fullAddress);
$order->setBillingAddress($fullAddress);
}
}
// Ajouter les items de la commande
foreach ($cart->getItems() as $cartItem) {
$orderItem = new \App\Entity\OrderItem();
$orderItem->setOrder($order);
$orderItem->setProduct($cartItem->getProduct());
$orderItem->setQuantity($cartItem->getQuantity());
$orderItem->setUnitPrice((string) $cartItem->getUnitPrice());
$orderItem->setTotalPrice((string) $cartItem->getTotalPrice());
$em->persist($orderItem);
// Incrémenter le compteur de ventes si la commande est payée
if ($order->getPaymentStatus() === 'paid') {
$this->incrementProductSalesCount($cartItem->getProduct(), $cartItem->getQuantity(), $em);
}
}
// Appliquer la carte cadeau si fournie
if ($giftCardCode && $giftCardDiscount > 0) {
$giftCard = $em->getRepository(\App\Entity\GiftCard::class)->findOneBy(['code' => $giftCardCode]);
if ($giftCard) {
$newBalance = max(0, (float) $giftCard->getBalance() - $giftCardDiscount);
$giftCard->setBalance((string) $newBalance);
$giftCard->setLastUsedAt(new \DateTimeImmutable('now'));
$em->persist($giftCard);
}
}
// Désactiver le panier
$cart->setIsActive(false);
$em->persist($order);
$em->flush();
// Envoyer des notifications
// Notification pour le client
$this->notificationService->createOrderCreatedNotification($user, $order);
// Notifications pour les vendeurs (un par boutique)
$shopsNotified = [];
foreach ($order->getItems() as $orderItem) {
$product = $orderItem->getProduct();
$shop = $product->getShop();
if ($shop && !in_array($shop->getId(), $shopsNotified)) {
$shopOwner = $shop->getManager()->first();
if ($shopOwner) {
$this->notificationService->createOrderReceivedNotification($shopOwner, $order, $shop);
$shopsNotified[] = $shop->getId();
}
}
}
// Nettoyer la session
$session->remove('moncash_order_data');
// Envoyer un message de succès à la fenêtre parente si c'est une popup
return $this->render('home/moncash-simulation.html.twig', [
'order' => $order,
'success' => true,
'redirectUrl' => $this->generateUrl('ui_account_orders')
]);
} catch (\Exception $e) {
$this->addFlash('error', 'Erreur lors de la création de la commande: ' . $e->getMessage());
return $this->redirectToRoute('ui_checkout');
}
}
#[Route('/account/', name: 'account_index')]
public function accountIndex(EntityManagerInterface $em): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
$user = $this->getUser();
// Récupérer les commandes récentes (5 dernières)
$recentOrders = $em->getRepository(Order::class)
->createQueryBuilder('o')
->where('o.customer = :user')
->setParameter('user', $user)
->orderBy('o.orderedAt', 'DESC')
->setMaxResults(5)
->getQuery()
->getResult();
// Compter le total de commandes
$totalOrders = $em->getRepository(Order::class)
->createQueryBuilder('o')
->select('COUNT(o.id)')
->where('o.customer = :user')
->setParameter('user', $user)
->getQuery()
->getSingleScalarResult();
// Récupérer les produits de la wishlist (5 derniers)
$wishlistItems = $this->wishlistService->getWishlistProducts($user);
$wishlistProducts = array_map(fn($item) => $item->getProduct(), array_slice($wishlistItems, 0, 5));
$totalWishlistCount = count($wishlistItems);
return $this->render('account/account.html.twig', [
'recentOrders' => $recentOrders,
'totalOrders' => $totalOrders,
'wishlistProducts' => $wishlistProducts,
'totalWishlistCount' => $totalWishlistCount,
'active' => 'profile'
]);
}
#[Route('/account/recently_viewed', name: 'account_recently_viewed')]
public function accountRecentlyViewed(Request $request, ViewTrackingService $viewTrackingService): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
$page = $request->query->getInt('page', 1);
$limit = 12;
$sortBy = $request->query->get('sort', 'recent');
$clear = $request->query->get('clear');
// Vider l'historique si demandé
if ($clear) {
$session = $request->getSession();
$session->remove('viewed_products');
$this->addFlash('success', 'Historique des produits vus vidé avec succès.');
return $this->redirectToRoute('ui_account_recently_viewed');
}
// Récupérer tous les produits récemment vus
$allRecentlyViewedProducts = $viewTrackingService->getRecentlyViewedProducts(100);
// Appliquer le tri
switch ($sortBy) {
case 'price_asc':
usort($allRecentlyViewedProducts, function($a, $b) {
return $a->getPrice() <=> $b->getPrice();
});
break;
case 'price_desc':
usort($allRecentlyViewedProducts, function($a, $b) {
return $b->getPrice() <=> $a->getPrice();
});
break;
case 'name':
usort($allRecentlyViewedProducts, function($a, $b) {
return strcmp($a->getName(), $b->getName());
});
break;
case 'recent':
default:
// Garder l'ordre par défaut (plus récent en premier)
break;
}
// Pagination
$totalProducts = count($allRecentlyViewedProducts);
$totalPages = ceil($totalProducts / $limit);
$offset = ($page - 1) * $limit;
$recentlyViewedProducts = array_slice($allRecentlyViewedProducts, $offset, $limit);
return $this->render('account/recently_viewed.html.twig', [
'recentlyViewedProducts' => $recentlyViewedProducts,
'current_page' => $page,
'total_pages' => $totalPages,
'total_products' => $totalProducts,
'sort_by' => $sortBy,
]);
}
#[Route('/account/followed_shops', name: 'account_followed_shops')]
public function accountFollowedShops(ShopFollowService $shopFollowService): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
// Récupérer les boutiques suivies par l'utilisateur
$followedShops = $shopFollowService->getFollowedShopsByUser($this->getUser());
return $this->render('account/followed_shops.html.twig', [
'followedShops' => $followedShops,
]);
}
#[Route('/account/orders', name: 'account_orders')]
public function accountOrders(EntityManagerInterface $em, Request $request): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
$page = $request->query->getInt('page', 1);
$limit = 10;
$offset = ($page - 1) * $limit;
$orders = $em->getRepository(Order::class)
->createQueryBuilder('o')
->where('o.customer = :user')
->setParameter('user', $this->getUser())
->orderBy('o.orderedAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
$totalOrders = $em->getRepository(Order::class)
->createQueryBuilder('o')
->select('COUNT(o.id)')
->where('o.customer = :user')
->setParameter('user', $this->getUser())
->getQuery()
->getSingleScalarResult();
$totalPages = ceil($totalOrders / $limit);
$paymentMethods = $em->getRepository(PaymentMethod::class)->findBy(['isActive' => true], ['sortOrder' => 'ASC']);
return $this->render('account/orders.html.twig', [
'orders' => $orders,
'current_page' => $page,
'total_pages' => $totalPages,
'active' => 'orders',
'paymentMethods' => $paymentMethods,
]);
}
#[Route('/account/orders/{id}/submit-payment-proof', name: 'account_submit_payment_proof', methods: ['POST'])]
public function accountSubmitPaymentProof(int $id, Request $request, EntityManagerInterface $em): JsonResponse
{
$user = $this->getUser();
if (!$user) {
return new JsonResponse(['ok' => false, 'message' => 'Vous devez être connecté'], 401);
}
$order = $em->getRepository(Order::class)->find($id);
if (!$order) {
return new JsonResponse(['ok' => false, 'message' => 'Commande introuvable'], 404);
}
if ($order->getCustomer()->getId() !== $user->getId()) {
return new JsonResponse(['ok' => false, 'message' => 'Accès non autorisé à cette commande'], 403);
}
$orderCode = trim((string) $request->request->get('orderCode', ''));
$notes = trim((string) $request->request->get('notes', ''));
$paymentMethodId = $request->request->get('paymentMethodId');
$uploadedFile = $request->files->get('proofImage');
if (!$uploadedFile) {
return new JsonResponse(['ok' => false, 'message' => 'Image de preuve requise'], 400);
}
if ($orderCode === '' || $orderCode !== $order->getOrderNumber()) {
return new JsonResponse(['ok' => false, 'message' => 'Code de commande invalide'], 400);
}
$allowedMime = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($uploadedFile->getMimeType(), $allowedMime)) {
return new JsonResponse(['ok' => false, 'message' => 'Format d\'image non supporté'], 400);
}
try {
$projectDir = $this->getParameter('kernel.project_dir');
$targetDir = $projectDir . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'payment_proofs';
if (!is_dir($targetDir)) {
@mkdir($targetDir, 0775, true);
}
$ext = $uploadedFile->guessExtension() ?: 'jpg';
$safeName = 'proof_' . $order->getId() . '_' . time() . '.' . $ext;
$uploadedFile->move($targetDir, $safeName);
$relativePath = '/uploads/payment_proofs/' . $safeName;
$methodName = null;
if ($paymentMethodId) {
$pm = $em->getRepository(PaymentMethod::class)->find($paymentMethodId);
if ($pm) {
$methodName = $pm->getName();
$order->setPaymentMethod($methodName);
}
}
$existingNotes = $order->getNotes() ?? '';
$append = sprintf(
'Preuve de paiement soumise: %s | moyen: %s | code: %s | à: %s',
$relativePath,
$methodName ?: 'manuel',
$orderCode,
(new \DateTimeImmutable())->format('c')
);
$order->setNotes(trim($existingNotes . PHP_EOL . $append));
$order->setPaymentStatus('pending');
$order->setStatus('payment_pending');
$em->flush();
return new JsonResponse(['ok' => true]);
} catch (\Exception $e) {
return new JsonResponse(['ok' => false, 'message' => 'Erreur lors du téléversement de la preuve'], 500);
}
}
#[Route('/account/orders/{id}', name: 'account_order_show')]
public function accountOrderShow(int $id, EntityManagerInterface $em): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
$order = $em->getRepository(Order::class)->find($id);
if (!$order) {
$this->addFlash('error', 'Commande introuvable.');
return $this->redirectToRoute('ui_account_orders');
}
// Vérifier que la commande appartient à l'utilisateur connecté
if ($order->getCustomer()->getId() !== $this->getUser()->getId()) {
$this->addFlash('error', 'Vous n\'avez pas accès à cette commande.');
return $this->redirectToRoute('ui_account_orders');
}
// Recalculer le sous-total à partir des OrderItems pour garantir l'exactitude
$calculatedSubtotal = 0.0;
foreach ($order->getItems() as $item) {
$calculatedSubtotal += (float) $item->getTotalPrice();
}
// Si le sous-total stocké ne correspond pas au calcul, le corriger
if (abs((float)$order->getSubtotal() - $calculatedSubtotal) > 0.01) {
$order->setSubtotal(number_format($calculatedSubtotal, 2, '.', ''));
// Recalculer aussi le total
$shippingAmount = (float) $order->getShippingAmount();
$discountAmount = (float) $order->getDiscountAmount();
$taxAmount = (float) $order->getTaxAmount();
$totalAmount = $calculatedSubtotal + $shippingAmount - $discountAmount + $taxAmount;
$order->setTotalAmount(number_format($totalAmount, 2, '.', ''));
$em->flush();
}
return $this->render('account/order_show.html.twig', [
'order' => $order,
'active' => 'orders'
]);
}
#[Route('/account/wishlist', name: 'account_wishlist')]
public function accountWishlist(EntityManagerInterface $em, \App\Service\WishlistService $wishlistService): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
$wishlistItems = $wishlistService->getWishlistProducts($this->getUser());
$products = array_map(fn($item) => $item->getProduct(), $wishlistItems);
return $this->render('account/wishlist.html.twig', [
'wishlist' => $products,
'active' => 'wishlist'
]);
}
#[Route('/account/saved-searches', name: 'account_saved_searches')]
public function accountSavedSearches(): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
// TODO: Implémenter recherches enregistrées
return $this->render('account/saved_searches.html.twig', [
'searches' => [],
'active' => 'search'
]);
}
#[Route('/account/transactions', name: 'account_transactions')]
public function accountTransactions(EntityManagerInterface $em, Request $request): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
// Les transactions sont basées sur les commandes
$orders = $em->getRepository(Order::class)
->createQueryBuilder('o')
->where('o.customer = :user')
->setParameter('user', $this->getUser())
->orderBy('o.orderedAt', 'DESC')
->getQuery()
->getResult();
return $this->render('account/transactions.html.twig', [
'orders' => $orders,
'active' => 'transactions'
]);
}
#[Route('/account/payment-methods', name: 'account_payment_methods')]
public function accountPaymentMethods(EntityManagerInterface $em): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
$paymentMethods = $em->getRepository(PaymentMethod::class)->findBy(['isActive' => true]);
return $this->render('account/payment_methods.html.twig', [
'paymentMethods' => $paymentMethods,
'active' => 'payment'
]);
}
#[Route('/account/coupons', name: 'account_coupons')]
public function accountCoupons(): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
// TODO: Implémenter système de coupons
return $this->render('account/coupons.html.twig', [
'coupons' => [],
'active' => 'coupons'
]);
}
#[Route('/account/gift-cards', name: 'account_gift_cards')]
public function accountGiftCards(GiftCardService $giftCardService): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
$user = $this->getUser();
$giftCards = $giftCardService->getUserGiftCards($user);
return $this->render('account/gift_cards.html.twig', [
'giftCards' => $giftCards,
'active' => 'giftcards'
]);
}
#[Route('/gift-card/purchase', name: 'gift_card_purchase', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function purchaseGiftCard(Request $request, GiftCardService $giftCardService, EntityManagerInterface $em): Response
{
$user = $this->getUser();
$giftCard = new GiftCard();
$form = $this->createForm(GiftCardPurchaseType::class, $giftCard);
// Récupérer les moyens de paiement actifs
$paymentMethods = $em->getRepository(PaymentMethod::class)->findBy(['isActive' => true], ['name' => 'ASC']);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Vérifier qu'un moyen de paiement a été sélectionné
$paymentMethodId = $request->request->get('paymentMethod');
if (!$paymentMethodId) {
$this->addFlash('error', 'Veuillez sélectionner un moyen de paiement.');
return $this->render('gift_card/purchase.html.twig', [
'form' => $form->createView(),
'paymentMethods' => $paymentMethods,
'current_menu' => 'giftcard'
]);
}
$paymentMethod = $em->getRepository(PaymentMethod::class)->find($paymentMethodId);
if (!$paymentMethod || !$paymentMethod->isActive()) {
$this->addFlash('error', 'Moyen de paiement invalide.');
return $this->render('gift_card/purchase.html.twig', [
'form' => $form->createView(),
'paymentMethods' => $paymentMethods,
'current_menu' => 'giftcard'
]);
}
$amount = (float)$giftCard->getInitialAmount();
$recipientEmail = $giftCard->getRecipientEmail();
$recipientName = $giftCard->getRecipientName();
$recipient = null;
// Vérifier si l'email correspond à un utilisateur existant
if ($recipientEmail) {
$recipient = $em->getRepository(User::class)->findOneBy(['email' => $recipientEmail]);
// Vérifier que le destinataire n'est pas l'acheteur lui-même
if ($recipient && $recipient->getId() === $user->getId()) {
$this->addFlash('error', 'Vous ne pouvez pas offrir une carte cadeau à vous-même.');
return $this->render('gift_card/purchase.html.twig', [
'form' => $form->createView(),
'paymentMethods' => $paymentMethods,
'current_menu' => 'giftcard'
]);
}
}
// Créer une commande pour la carte cadeau
$order = new Order();
$order->setOrderNumber('GC-' . strtoupper(uniqid()));
$order->setCustomer($user);
$order->setSubtotal((string)$amount);
$order->setTaxAmount('0.00');
$order->setShippingAmount('0.00');
$order->setDiscountAmount('0.00');
$order->setTotalAmount((string)$amount);
$order->setCurrency('HTG');
$order->setStatus('pending');
$order->setPaymentStatus('pending');
$order->setPaymentMethod($paymentMethod->getName());
$order->setNotes('Achat de carte cadeau - ' . ($recipientName ?? 'Sans destinataire'));
$order->setOrderedAt(new \DateTimeImmutable('now'));
$em->persist($order);
$em->flush();
// Traiter le paiement selon le type de moyen de paiement
$paymentType = strtolower($paymentMethod->getProvider() ?? $paymentMethod->getType() ?? '');
// Si c'est MonCash, utiliser le service MonCash
if (strpos($paymentType, 'moncash') !== false || strpos($paymentType, 'mon cash') !== false) {
// Rediriger vers une page de traitement MonCash pour la carte cadeau
$session = $request->getSession();
$session->set('gift_card_purchase_data', [
'orderId' => $order->getId(),
'amount' => $amount,
'recipientEmail' => $recipientEmail,
'recipientName' => $giftCard->getRecipientName(),
'message' => $giftCard->getMessage(),
'paymentMethodId' => $paymentMethod->getId()
]);
return $this->redirectToRoute('ui_gift_card_payment_process', [
'orderId' => $order->getId()
]);
}
// Pour les autres moyens de paiement (carte bancaire, PayPal, etc.), simuler le paiement réussi
// Dans un système réel, il faudrait intégrer avec les APIs de paiement correspondantes
$order->setPaymentStatus('paid');
$order->setStatus('completed');
$em->flush();
// Créer la carte cadeau après paiement réussi
$newGiftCard = $giftCardService->createGiftCard(
$amount,
$user,
$recipient,
$recipientEmail,
$giftCard->getRecipientName(),
$giftCard->getMessage(),
null,
$order
);
$this->addFlash('success', 'Carte cadeau créée avec succès ! Code: ' . $newGiftCard->getCode() . ' (Paiement via ' . $paymentMethod->getName() . ')');
// TODO: Envoyer un email au destinataire si email fourni
return $this->redirectToRoute('ui_account_gift_cards');
}
return $this->render('gift_card/purchase.html.twig', [
'form' => $form->createView(),
'paymentMethods' => $paymentMethods,
'current_menu' => 'giftcard'
]);
}
#[Route('/gift-card/payment/{orderId}', name: 'gift_card_payment_process', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function processGiftCardPayment(Request $request, EntityManagerInterface $em, GiftCardService $giftCardService, int $orderId): Response
{
$user = $this->getUser();
$order = $em->getRepository(Order::class)->find($orderId);
if (!$order || $order->getCustomer()->getId() !== $user->getId()) {
$this->addFlash('error', 'Commande introuvable.');
return $this->redirectToRoute('ui_gift_card_purchase');
}
$session = $request->getSession();
$purchaseData = $session->get('gift_card_purchase_data');
if (!$purchaseData || $purchaseData['orderId'] !== $orderId) {
$this->addFlash('error', 'Données de commande invalides.');
return $this->redirectToRoute('ui_gift_card_purchase');
}
// Si c'est une requête POST (retour de MonCash), traiter le paiement
if ($request->isMethod('POST')) {
// Traiter le paiement MonCash (similaire au checkout)
// Pour simplifier, on simule le paiement réussi
// Dans un système réel, il faudrait vérifier le statut avec MonCash
$order->setPaymentStatus('paid');
$order->setStatus('completed');
$em->flush();
// Récupérer le destinataire si email fourni
$recipient = null;
if ($purchaseData['recipientEmail']) {
$recipient = $em->getRepository(User::class)->findOneBy(['email' => $purchaseData['recipientEmail']]);
}
// Créer la carte cadeau
$newGiftCard = $giftCardService->createGiftCard(
$purchaseData['amount'],
$user,
$recipient,
$purchaseData['recipientEmail'],
$purchaseData['recipientName'],
$purchaseData['message'],
null,
$order
);
$session->remove('gift_card_purchase_data');
$this->addFlash('success', 'Carte cadeau créée avec succès ! Code: ' . $newGiftCard->getCode());
return $this->redirectToRoute('ui_account_gift_cards');
}
// Afficher la page de traitement du paiement
return $this->render('gift_card/payment_process.html.twig', [
'order' => $order,
'amount' => $purchaseData['amount'],
'current_menu' => 'giftcard'
]);
}
#[Route('/gift-card/redeem', name: 'gift_card_redeem', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function redeemGiftCard(Request $request, GiftCardService $giftCardService): JsonResponse
{
$code = $request->request->get('code', '');
$code = strtoupper(trim($code));
$validation = $giftCardService->validateGiftCardCode($code, $this->getUser());
if (!$validation['valid']) {
return $this->json([
'success' => false,
'message' => $validation['message']
], 400);
}
$giftCard = $validation['giftCard'];
return $this->json([
'success' => true,
'giftCard' => [
'id' => $giftCard->getId(),
'code' => $giftCard->getCode(),
'balance' => $giftCard->getBalance(),
'currency' => $giftCard->getCurrency()
],
'message' => 'Carte cadeau valide'
]);
}
#[Route('/checkout/complete-with-gift-card', name: 'checkout_complete_gift_card', methods: ['POST'])]
public function completeOrderWithGiftCard(
Request $request,
EntityManagerInterface $em,
GiftCardService $giftCardService
): JsonResponse {
$user = $this->getUser();
if (!$user) {
return new JsonResponse(['ok' => false, 'message' => 'Vous devez être connecté'], 401);
}
$data = json_decode($request->getContent(), true);
// Récupérer les données de la commande
$shippingMethodId = $data['shippingMethod'] ?? null;
$deliveryAddressId = $data['deliveryAddress'] ?? null;
$orderNotes = $data['orderNotes'] ?? '';
$giftCardCode = isset($data['giftCardCode']) && !empty($data['giftCardCode']) ? strtoupper(trim($data['giftCardCode'])) : null;
// Log pour débogage
error_log('CompleteOrderWithGiftCard - Data received: ' . json_encode($data));
error_log('CompleteOrderWithGiftCard - GiftCardCode: ' . ($giftCardCode ?? 'NULL'));
// Si aucun code de carte cadeau n'est fourni, retourner une erreur
if (!$giftCardCode) {
return new JsonResponse([
'ok' => false,
'message' => 'Aucune carte cadeau fournie. Veuillez d\'abord appliquer une carte cadeau valide.'
], 400);
}
// Validation des champs requis
if (!$shippingMethodId || !$deliveryAddressId) {
return new JsonResponse([
'ok' => false,
'message' => 'Adresse de livraison et moyen de livraison requis'
], 400);
}
// Récupérer le panier
$cart = $em->getRepository(Cart::class)->findOneBy([
'user' => $user,
'isActive' => true
]);
if (!$cart || $cart->getItems()->isEmpty()) {
return new JsonResponse(['ok' => false, 'message' => 'Panier vide'], 400);
}
// Récupérer la carte cadeau si fournie
$giftCard = null;
if ($giftCardCode) {
$validation = $giftCardService->validateGiftCardCode($giftCardCode, $user);
if (!$validation['valid']) {
return new JsonResponse([
'ok' => false,
'message' => $validation['message']
], 400);
}
$giftCard = $validation['giftCard'];
// Vérifier si la carte a un destinataire spécifique
if ($giftCard->getRecipient()) {
// Si la carte a un destinataire, seul ce destinataire peut l'utiliser
if ($giftCard->getRecipient()->getId() !== $user->getId()) {
return new JsonResponse([
'ok' => false,
'message' => 'Cette carte cadeau a été offerte à quelqu\'un d\'autre et ne peut être utilisée que par le destinataire.'
], 403);
}
} else {
// Si pas de destinataire, seul l'acheteur peut l'utiliser
if ($giftCard->getPurchasedBy()->getId() !== $user->getId()) {
return new JsonResponse([
'ok' => false,
'message' => 'Cette carte cadeau ne vous appartient pas'
], 403);
}
}
}
try {
// Calculer les totaux en additionnant les items du panier
// IMPORTANT: Calculer le sous-total en additionnant les totalPrice de chaque item
// pour garantir la cohérence avec les OrderItems créés
$subtotal = 0.0;
foreach ($cart->getItems() as $cartItem) {
$subtotal += (float) $cartItem->getTotalPrice();
}
$shippingMethod = $em->getRepository(ShippingMethod::class)->find($shippingMethodId);
if (!$shippingMethod) {
return new JsonResponse(['ok' => false, 'message' => 'Moyen de livraison invalide'], 400);
}
$shippingAmount = (float) $shippingMethod->getPrice();
$totalBeforeTax = $subtotal + $shippingAmount;
// Log pour débogage
error_log('CompleteOrderWithGiftCard - Subtotal: ' . $subtotal . ', Shipping: ' . $shippingAmount . ', TotalBeforeTax: ' . $totalBeforeTax);
error_log('CompleteOrderWithGiftCard - GiftCard: ' . ($giftCard ? 'Found (ID: ' . $giftCard->getId() . ', Balance: ' . $giftCard->getBalance() . ')' : 'NULL'));
// Calculer la réduction carte cadeau sur le montant avant taxe (comme dans le JavaScript)
$giftCardDiscount = 0;
if ($giftCard) {
$balance = (float) $giftCard->getBalance();
// La carte cadeau peut couvrir jusqu'au total avant taxe
$giftCardDiscount = min($balance, $totalBeforeTax);
error_log('CompleteOrderWithGiftCard - GiftCardDiscount: ' . $giftCardDiscount);
} else {
error_log('CompleteOrderWithGiftCard - Aucune carte cadeau trouvée, giftCardCode: ' . ($giftCardCode ?? 'NULL'));
}
// Calculer le montant restant après réduction carte cadeau
$totalBeforeTaxAfterDiscount = $totalBeforeTax - $giftCardDiscount;
// Calculer la taxe sur le montant restant APRÈS réduction (comme dans le JavaScript)
$taxAmount = $totalBeforeTaxAfterDiscount * 0.01;
// Le total final après réduction et taxe
$totalAmount = $totalBeforeTaxAfterDiscount + $taxAmount;
error_log('CompleteOrderWithGiftCard - TotalAmount: ' . $totalAmount . ', TaxAmount: ' . $taxAmount);
// Vérifier que le total est bien <= 0 (avec une petite tolérance pour les arrondis)
// Si la carte cadeau couvre exactement le total avant taxe, il reste seulement la taxe
// Dans ce cas, on permet que la carte cadeau couvre aussi la taxe si elle le peut
if ($totalAmount > 0.01) {
// Si la carte cadeau a encore du solde après avoir couvert le total avant taxe
if ($giftCard && $giftCardDiscount >= $totalBeforeTax) {
$remainingBalance = (float) $giftCard->getBalance() - $giftCardDiscount;
if ($remainingBalance >= $taxAmount) {
// La carte cadeau peut couvrir aussi la taxe
$giftCardDiscount = $totalBeforeTax + $taxAmount;
$taxAmount = 0;
$totalAmount = 0;
} else {
// La carte ne peut pas couvrir la taxe, retourner une erreur avec un message clair
return new JsonResponse([
'ok' => false,
'message' => 'Votre carte cadeau couvre le montant avant taxe, mais il reste ' . number_format($taxAmount, 2) . ' HTG de taxe à payer. Le solde restant de votre carte (' . number_format($remainingBalance, 2) . ' HTG) est insuffisant pour couvrir la taxe.'
], 400);
}
} else {
// La carte ne couvre même pas le montant avant taxe
return new JsonResponse([
'ok' => false,
'message' => 'Le montant restant (' . number_format($totalAmount, 2) . ' HTG) doit être inférieur ou égal à zéro pour utiliser cette méthode. Votre carte cadeau ne couvre pas entièrement la commande.'
], 400);
}
}
// Récupérer l'adresse de livraison
$address = $em->getRepository(Address::class)->find($deliveryAddressId);
if (!$address) {
return new JsonResponse(['ok' => false, 'message' => 'Adresse de livraison invalide'], 400);
}
// Créer la commande
$order = new Order();
$order->setOrderNumber('ORD-' . strtoupper(uniqid()));
$order->setCustomer($user);
$order->setSubtotal((string) $subtotal);
$order->setTaxAmount((string) $taxAmount);
$order->setShippingAmount((string) $shippingAmount);
$order->setDiscountAmount((string) $giftCardDiscount);
$order->setTotalAmount((string) max(0, $totalAmount)); // S'assurer que le total n'est pas négatif
$order->setCurrency('HTG');
$order->setStatus('pending');
$order->setPaymentStatus('paid'); // Paiement complet via carte cadeau
$order->setPaymentMethod('Gift Card');
$order->setShippingMethod($shippingMethod->getName());
$order->setNotes($orderNotes);
$order->setOrderedAt(new \DateTimeImmutable('now'));
// Ajouter l'adresse de livraison
$fullAddress = sprintf(
'%s, %s, %s, %s%s',
$address->getStreet(),
$address->getCity(),
$address->getState(),
$address->getCountry(),
$address->getZipCode() ? ' ' . $address->getZipCode() : ''
);
$order->setShippingAddress($fullAddress);
$order->setBillingAddress($fullAddress);
// Ajouter les items de la commande
foreach ($cart->getItems() as $cartItem) {
$orderItem = new \App\Entity\OrderItem();
$orderItem->setOrder($order);
$orderItem->setProduct($cartItem->getProduct());
$orderItem->setQuantity($cartItem->getQuantity());
$orderItem->setUnitPrice((string) $cartItem->getUnitPrice());
$orderItem->setTotalPrice((string) $cartItem->getTotalPrice());
$em->persist($orderItem);
// Incrémenter le compteur de ventes si la commande est payée
if ($order->getPaymentStatus() === 'paid') {
$this->incrementProductSalesCount($cartItem->getProduct(), $cartItem->getQuantity(), $em);
}
}
// Désactiver le panier
$cart->setIsActive(false);
// Persister l'order d'abord pour qu'il ait un ID
$em->persist($order);
$em->flush();
// Utiliser la carte cadeau si applicable (après que l'order soit persisté)
// Passer l'EntityManager pour s'assurer que tout utilise le même contexte
if ($giftCard && $giftCardDiscount > 0) {
$giftCardService->useGiftCard($giftCard, $giftCardDiscount, $order, $em);
}
// Envoyer des notifications
// Notification pour le client
$this->notificationService->createOrderCreatedNotification($user, $order);
// Notifications pour les vendeurs (un par boutique)
$shopsNotified = [];
foreach ($order->getItems() as $orderItem) {
$product = $orderItem->getProduct();
$shop = $product->getShop();
if ($shop && !in_array($shop->getId(), $shopsNotified)) {
$shopOwner = $shop->getManager()->first();
if ($shopOwner) {
$this->notificationService->createOrderReceivedNotification($shopOwner, $order, $shop);
$shopsNotified[] = $shop->getId();
}
}
}
return new JsonResponse([
'ok' => true,
'message' => 'Commande créée avec succès',
'orderId' => $order->getId(),
'orderNumber' => $order->getOrderNumber(),
'redirectUrl' => $this->generateUrl('ui_account_orders')
]);
} catch (\Exception $e) {
return new JsonResponse([
'ok' => false,
'message' => 'Erreur lors de la création de la commande: ' . $e->getMessage()
], 500);
}
}
#[Route('/account/settings', name: 'account_settings')]
public function accountSettings(Request $request, EntityManagerInterface $em): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
$user = $this->getUser();
if ($request->isMethod('POST')) {
$userFromToken = $this->getUser();
$userId = $userFromToken->getId();
$userEntity = $em->getRepository(User::class)->find($userId);
if (!$userEntity) {
throw $this->createNotFoundException('Utilisateur introuvable');
}
$firstname = trim((string)$request->request->get('firstname', ''));
$lastname = trim((string)$request->request->get('lastname', ''));
$phone = trim((string)$request->request->get('phone', ''));
$gender = $request->request->get('gender');
if ($firstname !== '') {
$userEntity->setFirstname($firstname);
}
if ($lastname !== '') {
$userEntity->setLastname($lastname);
}
if ($phone !== '') {
$userEntity->setPhone($phone);
}
if ($gender !== null && $gender !== '') {
$userEntity->setGender($gender);
}
$em->persist($userEntity);
$em->flush();
$this->addFlash('success', 'Paramètres mis à jour avec succès.');
return $this->redirectToRoute('ui_account_settings');
}
return $this->render('account/settings.html.twig', [
'user' => $user,
'active' => 'settings'
]);
}
#[Route('/account/kyc', name: 'account_kyc')]
public function accountKyc(Request $request, EntityManagerInterface $em): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
$userFromToken = $this->getUser(); // c’est un UserInterface
$userId = $userFromToken->getId();
$user = $em->getRepository(User::class)->find($userId);
if (!$user) {
throw $this->createNotFoundException('Utilisateur introuvable');
}
$form = $this->createForm(KycFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (
$user->getFrontDocumentSubmitted() && $user->getSelfieSubmitted()
&& $user->getBackDocumentSubmitted() && $user->getFirstname()
&& $user->getLastname() && $user->getPhone() && $user->getGender()
) {
$user->setKycStatus('pending');
$user->setKycSubmittedAt(new DateTimeImmutable('now'));
// Vérifier et ajouter ROLE_SELLER s’il n’existe pas déjà
$roles = $user->getRoles();
if (!in_array('ROLE_SELLER', $roles, true)) {
$roles[] = 'ROLE_SELLER';
$user->setRoles($roles);
}
}
$em->persist($user);
$em->flush();
$this->addFlash('success', 'Vos documents ont été soumis avec succès. En attente de validation.');
return $this->redirectToRoute('ui_account_kyc');
}
return $this->render('account/kyc.html.twig', [
'form' => $form->createView(),
'user' => $user
]);
}
#[Route('/kyc/upload/{type}', name: 'kyc_upload', methods: ['POST'])]
public function upload(Request $request, string $type, EntityManagerInterface $em): JsonResponse
{
if (!$this->getUser()) {
return new JsonResponse(['error' => 'Vous devez être connecté pour uploader des fichiers'], 401);
}
$file = $request->files->get('file');
if (!$file) {
return new JsonResponse(['error' => 'Aucun fichier n\'a été reçu. Veuillez sélectionner un fichier.'], 400);
}
// Vérifier les erreurs d'upload PHP
if ($file->getError() !== UPLOAD_ERR_OK) {
$errorMessages = [
UPLOAD_ERR_INI_SIZE => 'Le fichier dépasse la limite de taille autorisée par le serveur (upload_max_filesize). Taille maximum : ' . ini_get('upload_max_filesize'),
UPLOAD_ERR_FORM_SIZE => 'Le fichier dépasse la limite de taille autorisée par le formulaire.',
UPLOAD_ERR_PARTIAL => 'Le fichier n\'a été que partiellement uploadé.',
UPLOAD_ERR_NO_FILE => 'Aucun fichier n\'a été uploadé.',
UPLOAD_ERR_NO_TMP_DIR => 'Le dossier temporaire est manquant.',
UPLOAD_ERR_CANT_WRITE => 'Échec de l\'écriture du fichier sur le disque.',
UPLOAD_ERR_EXTENSION => 'Une extension PHP a arrêté l\'upload du fichier.',
];
$errorMessage = $errorMessages[$file->getError()] ?? 'Erreur inconnue lors de l\'upload.';
return new JsonResponse(['error' => $errorMessage], 400);
}
// Vérifier la taille du fichier (max 5MB)
$maxSize = 5 * 1024 * 1024; // 5MB
if ($file->getSize() > $maxSize) {
return new JsonResponse([
'error' => 'Le fichier est trop volumineux. Taille maximum : 5 Mo. Taille du fichier : ' . round($file->getSize() / 1024 / 1024, 2) . ' Mo'
], 400);
}
// Obtenir l'extension en utilisant getClientOriginalExtension() qui ne nécessite pas d'accès au fichier
$extension = $file->getClientOriginalExtension();
// Si l'extension est vide, utiliser le MIME type
if (empty($extension)) {
$mimeType = $file->getMimeType();
$extensionMap = [
'image/jpeg' => 'jpg',
'image/jpg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'application/pdf' => 'pdf',
];
$extension = $extensionMap[$mimeType] ?? 'bin';
}
// Nettoyer l'extension (enlever les points et caractères spéciaux)
$extension = strtolower(trim($extension, '.'));
$extension = preg_replace('/[^a-z0-9]/', '', $extension);
if (empty($extension)) {
$extension = 'bin';
}
$filename = uniqid() . '.' . $extension;
// Obtenir le répertoire KYC
$kycDirectory = $this->getParameter('kyc_directory');
// Créer le répertoire s'il n'existe pas
if (!is_dir($kycDirectory)) {
@mkdir($kycDirectory, 0755, true);
}
// Déplacer le fichier
try {
$file->move($kycDirectory, $filename);
} catch (\Exception $e) {
return new JsonResponse([
'error' => 'Erreur lors de l\'upload : ' . $e->getMessage()
], 500);
}
// Mettre à jour l’utilisateur
$user = $this->getUser();
if ($type === 'selfie') {
$user->setSelfieSubmitted($filename);
} elseif ($type === 'front') {
// Tu peux décider recto/verso selon logique
$user->setFrontDocumentSubmitted($filename);
} elseif ($type === 'back') {
// Tu peux décider recto/verso selon logique
$user->setBackDocumentSubmitted($filename);
}
$em->flush();
return new JsonResponse(['success' => true, 'filename' => $filename]);
}
#[Route('/account/address', name: 'account_address', methods: ['GET', 'POST'])]
public function indexAddress(AddressRepository $addressRepository, Request $request, EntityManagerInterface $entityManager): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
// Récupérer le paramètre de redirection
$redirect = $request->query->get('redirect');
$address = new Address();
$form = $this->createForm(AddressType::class, $address);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$address->setUser($this->getUser());
if ($address->isDefault()) {
// Mettre toutes les autres adresses de l'user à false
foreach ($this->getUser()->getAddresses() as $otherAddress) {
$otherAddress->setIsDefault(false);
$entityManager->persist($otherAddress);
}
}
$entityManager->persist($address);
$entityManager->flush();
// Rediriger selon le paramètre ou vers la page des adresses par défaut
if ($redirect === 'checkout') {
return $this->redirectToRoute('ui_checkout', [], Response::HTTP_SEE_OTHER);
}
return $this->redirectToRoute('ui_account_address', [], Response::HTTP_SEE_OTHER);
}
return $this->render('account/address.html.twig', [
'addresses' => $addressRepository->findBy(['user' => $this->getUser()]),
'address' => $address,
'newAddressForm' => $form->createView(),
'redirect' => $redirect,
]);
}
#[Route('/account/address/{id}', name: 'account_address_delete', methods: ['POST'])]
public function delete(Request $request, \App\Entity\Address $address, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete' . $address->getId(), $request->request->get('_token'))) {
$entityManager->remove($address);
$entityManager->flush();
}
// Rediriger selon le paramètre ou vers la page des adresses par défaut
$redirect = $request->query->get('redirect');
if ($redirect === 'checkout') {
return $this->redirectToRoute('ui_checkout', [], Response::HTTP_SEE_OTHER);
}
return $this->redirectToRoute('ui_account_address', [], Response::HTTP_SEE_OTHER);
}
#[Route('/register', name: 'app_register')]
public function register(
Request $request,
UserPasswordHasherInterface $userPasswordHasher,
EntityManagerInterface $entityManager,
MailerInterface $mailer
): Response
{
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
// Récupérer le code de parrainage depuis l'URL ou la session
$referralCode = $request->query->get('ref') ?? $request->getSession()->get('referral_code');
if ($form->isSubmitted() && $form->isValid()) {
// encode the plain password
$user->setPassword(
$userPasswordHasher->hashPassword(
$user,
$form->get('plainPassword')->getData()
)
);
$user->setCreatedAt(new DateTimeImmutable('now'));
$entityManager->persist($user);
$entityManager->flush();
// Traiter le parrainage si un code est présent
if ($referralCode) {
try {
$this->referralService->processReferralSignup($user, $referralCode);
$this->addFlash('success', 'Vous avez été inscrit via un lien de parrainage !');
} catch (\Exception $e) {
// Ne pas bloquer l'inscription si le parrainage échoue
// Log l'erreur si nécessaire
}
// Nettoyer le code de parrainage de la session
$request->getSession()->remove('referral_code');
}
// generate a signed url and email it to the user
$this->emailVerifier->sendEmailConfirmation(
'ui_app_verify_email',
$user,
(new TemplatedEmail())
->from(new EmailAddress('no-reply@maketou-ht.com', 'MaketOu'))
->to($user->getEmail())
->subject('Bienvenue sur MaketOu - Confirmez votre email')
->htmlTemplate('registration/confirmation_email.html.twig')
);
// Envoyer un email de bienvenue supplémentaire
$welcomeEmail = (new TemplatedEmail())
->from(new EmailAddress('no-reply@maketou-ht.com', 'MaketOu'))
->to($user->getEmail())
->subject('🎉 Bienvenue dans la communauté MaketOu !')
->htmlTemplate('emails/welcome.html.twig')
->context([
'user' => $user,
'app_url' => $request->getSchemeAndHttpHost(),
]);
try {
$mailer->send($welcomeEmail);
} catch (\Exception $e) {
// Ne pas bloquer l'inscription si l'email de bienvenue échoue
// Log l'erreur si nécessaire
}
// Message de succès
$this->addFlash('success', 'Votre compte a été créé avec succès ! Veuillez vérifier votre email pour confirmer votre inscription.');
return $this->redirectToRoute('ui_app_login');
}
// Stocker le code de parrainage en session si présent dans l'URL
if ($referralCode && !$form->isSubmitted()) {
$request->getSession()->set('referral_code', $referralCode);
}
return $this->render('registration/register.html.twig', [
'registrationForm' => $form->createView(),
'referralCode' => $referralCode,
]);
}
#[Route('/verify/email', name: 'app_verify_email')]
public function verifyUserEmail(Request $request, TranslatorInterface $translator): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
// validate email confirmation link, sets User::isVerified=true and persists
try {
$this->emailVerifier->handleEmailConfirmation($request, $this->getUser());
} catch (VerifyEmailExceptionInterface $exception) {
$this->addFlash('verify_email_error', $translator->trans($exception->getReason(), [], 'VerifyEmailBundle'));
return $this->redirectToRoute('ui_app_register');
}
// @TODO Change the redirect on success and handle or remove the flash message in your templates
$this->addFlash('success', 'Your email address has been verified.');
return $this->redirectToRoute('ui_app_register');
}
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
#[Route('/reset-password', name: 'app_forgot_password_request')]
public function requestPasswordReset(Request $request, EntityManagerInterface $em, MailerInterface $mailer): Response
{
$form = $this->createForm(ResetPasswordRequestFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$email = $form->get('email')->getData();
$user = $em->getRepository(User::class)->findOneBy(['email' => $email]);
// Ne pas révéler si l'email existe ou non pour des raisons de sécurité
if ($user) {
$resetToken = bin2hex(random_bytes(32));
$user->setResetToken($resetToken);
$user->setPasswordRequestedAt(new DateTimeImmutable('now'));
$em->flush();
// Envoyer l'email
$resetUrl = $this->generateUrl('ui_app_reset_password', ['token' => $resetToken], UrlGeneratorInterface::ABSOLUTE_URL);
$email = (new TemplatedEmail())
->from(new EmailAddress('no-reply@maketou-ht.com', 'MaketOu'))
->to($user->getEmail())
->subject('Réinitialisation de votre mot de passe')
->htmlTemplate('security/reset_password_email.html.twig')
->context([
'resetUrl' => $resetUrl,
'user' => $user,
]);
$mailer->send($email);
}
// Toujours afficher le même message pour éviter l'énumération d'emails
$this->addFlash('success', 'Si votre adresse email existe dans notre système, vous recevrez un lien pour réinitialiser votre mot de passe.');
return $this->redirectToRoute('ui_app_forgot_password_request');
}
return $this->render('security/reset_password_request.html.twig', [
'requestForm' => $form->createView(),
]);
}
#[Route('/reset-password/{token}', name: 'app_reset_password')]
public function resetPassword(string $token, Request $request, UserPasswordHasherInterface $userPasswordHasher, EntityManagerInterface $em): Response
{
$user = $em->getRepository(User::class)->findOneBy(['resetToken' => $token]);
if (!$user || !$user->isPasswordRequestNonExpired()) {
$this->addFlash('error', 'Le lien de réinitialisation est invalide ou a expiré.');
return $this->redirectToRoute('ui_app_forgot_password_request');
}
$form = $this->createForm(ResetPasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Encoder le nouveau mot de passe
$user->setPassword(
$userPasswordHasher->hashPassword(
$user,
$form->get('plainPassword')->getData()
)
);
// Effacer le token
$user->setResetToken(null);
$user->setPasswordRequestedAt(null);
$em->flush();
$this->addFlash('success', 'Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter.');
return $this->redirectToRoute('ui_app_login');
}
return $this->render('security/reset_password.html.twig', [
'resetForm' => $form->createView(),
]);
}
#[Route('/shop/{slug}', name: 'shop_show', requirements: ['slug' => '[a-z0-9-]+'])]
public function shopShow(string $slug, ProductRepository $productRepository, ViewTrackingService $viewTrackingService, ShopFollowService $shopFollowService, EntityManagerInterface $em): Response
{
// Normaliser le slug (trim, lowercase)
$slug = trim(strtolower($slug));
if (empty($slug)) {
throw $this->createNotFoundException('Slug de boutique invalide.');
}
// Utiliser le repository pour une recherche plus robuste
$shopRepository = $em->getRepository(Shop::class);
// Essayer d'abord avec la méthode optimisée
$shop = $shopRepository->findOneBySlug($slug);
// Si pas trouvé, essayer avec findOneBy standard
if (!$shop) {
$shop = $shopRepository->findOneBy(['slug' => $slug]);
}
// Si toujours pas trouvé, vérifier s'il y a des variations (espaces, majuscules, etc.)
if (!$shop) {
// Essayer une recherche case-insensitive via DQL
$qb = $shopRepository->createQueryBuilder('s');
$shop = $qb->where('LOWER(s.slug) = :slug')
->setParameter('slug', strtolower($slug))
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
if (!$shop) {
// Logger l'erreur pour le debugging en production
error_log("Shop not found with slug: " . $slug);
throw $this->createNotFoundException('Boutique non trouvée avec le slug: ' . htmlspecialchars($slug, ENT_QUOTES, 'UTF-8'));
}
// Vérifier que la boutique est active
if (!$shop->isIsActive()) {
throw $this->createNotFoundException('Cette boutique n\'est plus active.');
}
// Tracker la vue de la boutique
$viewTrackingService->trackShopView($shop);
// Récupérer les produits de cette boutique
$products = $productRepository->findBy([
'shop' => $shop,
'isActive' => true
], ['publishedAt' => 'DESC'], 12);
// Récupérer les statistiques détaillées via le service
$stats = $viewTrackingService->getShopViewStats($shop);
// Vérifier si l'utilisateur connecté suit cette boutique
$isFollowing = false;
if ($this->getUser()) {
$isFollowing = $shopFollowService->isUserFollowingShop($this->getUser(), $shop);
}
// Récupérer les statistiques de follow
$followStats = $shopFollowService->getShopFollowStats($shop);
// Permission d'édition pour le gestionnaire de la boutique
$canEdit = false;
if ($this->getUser()) {
$canEdit = $shop->getManager()->contains($this->getUser());
}
return $this->render('home/shop.html.twig', [
'shop' => $shop,
'products' => $products,
'stats' => $stats,
'isFollowing' => $isFollowing,
'followStats' => $followStats,
'canEdit' => $canEdit
]);
}
#[Route('/account/upload-profile-picture', name: 'account_upload_profile_picture', methods: ['POST'])]
public function uploadProfilePicture(Request $request, EntityManagerInterface $em): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->getUser();
if (!$user instanceof User) {
return $this->json(['success' => false, 'message' => 'Utilisateur non trouvé'], 401);
}
$file = $request->files->get('profilePicture');
if (!$file) {
return $this->json(['success' => false, 'message' => 'Aucun fichier fourni'], 400);
}
// Valider le type de fichier
$allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($file->getMimeType(), $allowedMimeTypes)) {
return $this->json(['success' => false, 'message' => 'Format de fichier non supporté'], 400);
}
// Valider la taille (max 5MB)
if ($file->getSize() > 5 * 1024 * 1024) {
return $this->json(['success' => false, 'message' => 'Le fichier est trop volumineux (max 5MB)'], 400);
}
// Générer un nom de fichier unique
$newFilename = 'profile_' . uniqid() . '.' . $file->guessExtension();
$uploadDir = $this->getParameter('kernel.project_dir') . '/public/uploads/profiles/';
// Créer le dossier s'il n'existe pas
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// Supprimer l'ancienne photo si elle existe
if ($user->getProfilePicture() && file_exists($this->getParameter('kernel.project_dir') . '/public/' . $user->getProfilePicture())) {
unlink($this->getParameter('kernel.project_dir') . '/public/' . $user->getProfilePicture());
}
// Déplacer le fichier
$file->move($uploadDir, $newFilename);
// Mettre à jour l'utilisateur
$user->setProfilePicture('uploads/profiles/' . $newFilename);
$em->flush();
return $this->json([
'success' => true,
'message' => 'Photo de profil mise à jour avec succès',
'url' => '/uploads/profiles/' . $newFilename
]);
}
#[Route('/api/track-product-view/{id}', name: 'api_track_product_view', methods: ['POST'])]
public function trackProductView(int $id, ViewTrackingService $viewTrackingService, EntityManagerInterface $em): Response
{
$product = $em->getRepository(Product::class)->find($id);
if (!$product) {
return $this->json(['success' => false, 'message' => 'Produit introuvable'], 404);
}
// Tracker la vue du produit
$viewTrackingService->trackProductView($product);
return $this->json([
'success' => true,
'viewCount' => $product->getViewCount(),
'message' => 'Vue enregistrée'
]);
}
#[Route('/api/track-shop-view/{id}', name: 'api_track_shop_view', methods: ['POST'])]
public function trackShopView(int $id, ViewTrackingService $viewTrackingService, EntityManagerInterface $em): Response
{
$shop = $em->getRepository(Shop::class)->find($id);
if (!$shop) {
return $this->json(['success' => false, 'message' => 'Boutique introuvable'], 404);
}
// Tracker la vue de la boutique
$viewTrackingService->trackShopView($shop);
return $this->json([
'success' => true,
'viewCount' => $shop->getViewCount(),
'message' => 'Vue enregistrée'
]);
}
#[Route('/api/shop/{id}/follow', name: 'api_shop_follow', methods: ['POST'])]
public function followShop(int $id, ShopFollowService $shopFollowService, EntityManagerInterface $em): JsonResponse
{
$user = $this->getUser();
if (!$user) {
return $this->json(['success' => false, 'message' => 'Vous devez être connecté pour suivre une boutique'], 401);
}
$shop = $em->getRepository(Shop::class)->find($id);
if (!$shop) {
return $this->json(['success' => false, 'message' => 'Boutique non trouvée'], 404);
}
$result = $shopFollowService->followShop($user, $shop);
return $this->json($result, $result['success'] ? 200 : 400);
}
#[Route('/api/shop/{id}/unfollow', name: 'api_shop_unfollow', methods: ['POST'])]
public function unfollowShop(int $id, ShopFollowService $shopFollowService, EntityManagerInterface $em): JsonResponse
{
$user = $this->getUser();
if (!$user) {
return $this->json(['success' => false, 'message' => 'Vous devez être connecté pour ne plus suivre une boutique'], 401);
}
$shop = $em->getRepository(Shop::class)->find($id);
if (!$shop) {
return $this->json(['success' => false, 'message' => 'Boutique non trouvée'], 404);
}
$result = $shopFollowService->unfollowShop($user, $shop);
return $this->json($result, $result['success'] ? 200 : 400);
}
#[Route('/api/shop/{id}/toggle-follow', name: 'api_shop_toggle_follow', methods: ['POST'])]
public function toggleFollowShop(int $id, ShopFollowService $shopFollowService, EntityManagerInterface $em): JsonResponse
{
$user = $this->getUser();
if (!$user) {
return $this->json(['success' => false, 'message' => 'Vous devez être connecté pour suivre une boutique'], 401);
}
$shop = $em->getRepository(Shop::class)->find($id);
if (!$shop) {
return $this->json(['success' => false, 'message' => 'Boutique non trouvée'], 404);
}
$result = $shopFollowService->toggleFollow($user, $shop);
return $this->json($result, $result['success'] ? 200 : 400);
}
#[Route('/api/shop/{id}/followers', name: 'api_shop_followers', methods: ['GET'])]
public function getShopFollowers(int $id, ShopFollowService $shopFollowService, EntityManagerInterface $em): JsonResponse
{
$shop = $em->getRepository(Shop::class)->find($id);
if (!$shop) {
return $this->json(['success' => false, 'message' => 'Boutique non trouvée'], 404);
}
$followers = $shopFollowService->getShopFollowers($shop);
$followStats = $shopFollowService->getShopFollowStats($shop);
return $this->json([
'success' => true,
'followers' => $followers,
'stats' => $followStats
]);
}
#[Route('/api/shop/{id}/products/sort', name: 'api_shop_products_sort', methods: ['POST'])]
public function sortShopProducts(int $id, Request $request, EntityManagerInterface $entityManager): JsonResponse
{
try {
$data = json_decode($request->getContent(), true);
$sortBy = $data['sortBy'] ?? '';
$shop = $entityManager->getRepository(Shop::class)->find($id);
if (!$shop) {
return $this->json(['success' => false, 'message' => 'Boutique non trouvée'], 404);
}
$queryBuilder = $entityManager->createQueryBuilder()
->select('p')
->from(Product::class, 'p')
->where('p.shop = :shop')
->andWhere('p.isActive = :active')
->setParameter('shop', $shop)
->setParameter('active', true);
// Appliquer le tri selon le critère sélectionné
switch ($sortBy) {
case 'price_asc':
$queryBuilder->orderBy('p.price', 'ASC');
break;
case 'price_desc':
$queryBuilder->orderBy('p.price', 'DESC');
break;
case 'newest':
$queryBuilder->orderBy('p.publishedAt', 'DESC');
break;
case 'popular':
$queryBuilder->orderBy('p.viewCount', 'DESC');
break;
case 'name_asc':
$queryBuilder->orderBy('p.name', 'ASC');
break;
case 'name_desc':
$queryBuilder->orderBy('p.name', 'DESC');
break;
default:
$queryBuilder->orderBy('p.publishedAt', 'DESC');
break;
}
$products = $queryBuilder->getQuery()->getResult();
// Rendre le template des produits
$html = $this->renderView('home/_products_list.html.twig', [
'products' => $products
]);
return $this->json([
'success' => true,
'html' => $html,
'count' => count($products)
]);
} catch (\Exception $e) {
return $this->json([
'success' => false,
'message' => 'Erreur lors du tri des produits: ' . $e->getMessage()
], 500);
}
}
#[Route('/api/wishlist/add/{id}', name: 'api_wishlist_add', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function addToWishlist(int $id): JsonResponse
{
$product = $this->entityManager->getRepository(Product::class)->find($id);
if (!$product) {
return $this->json([
'success' => false,
'message' => 'Produit non trouvé'
], 404);
}
$result = $this->wishlistService->addToWishlist($this->getUser(), $product);
return $this->json($result);
}
#[Route('/api/wishlist/remove/{id}', name: 'api_wishlist_remove', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function removeFromWishlist(int $id): JsonResponse
{
$product = $this->entityManager->getRepository(Product::class)->find($id);
if (!$product) {
return $this->json([
'success' => false,
'message' => 'Produit non trouvé'
], 404);
}
$result = $this->wishlistService->removeFromWishlist($this->getUser(), $product);
return $this->json($result);
}
#[Route('/api/wishlist/clear', name: 'api_wishlist_clear', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function clearWishlist(): JsonResponse
{
$result = $this->wishlistService->clearWishlist($this->getUser());
return $this->json($result);
}
#[Route('/api/wishlist/count', name: 'api_wishlist_count', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function getWishlistCount(): JsonResponse
{
$count = $this->wishlistService->getWishlistCount($this->getUser());
return $this->json([
'success' => true,
'count' => $count
]);
}
#[Route('/api/wishlist/status/{id}', name: 'api_wishlist_status', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function getWishlistStatus(int $id): JsonResponse
{
$product = $this->entityManager->getRepository(Product::class)->find($id);
if (!$product) {
return $this->json([
'success' => false,
'message' => 'Produit non trouvé'
], 404);
}
$isInWishlist = $this->wishlistService->isInWishlist($this->getUser(), $product);
return $this->json([
'success' => true,
'inWishlist' => $isInWishlist
]);
}
#[Route('/api/comparison/add/{id}', name: 'api_comparison_add', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function addToComparison(int $id): JsonResponse
{
$product = $this->entityManager->getRepository(Product::class)->find($id);
if (!$product) {
return $this->json([
'success' => false,
'message' => 'Produit non trouvé'
], 404);
}
$result = $this->comparisonService->addToComparison($this->getUser(), $product);
return $this->json($result);
}
#[Route('/api/comparison/remove/{id}', name: 'api_comparison_remove', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function removeFromComparison(int $id): JsonResponse
{
$product = $this->entityManager->getRepository(Product::class)->find($id);
if (!$product) {
return $this->json([
'success' => false,
'message' => 'Produit non trouvé'
], 404);
}
$result = $this->comparisonService->removeFromComparison($this->getUser(), $product);
return $this->json($result);
}
#[Route('/api/comparison/clear', name: 'api_comparison_clear', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function clearComparison(): JsonResponse
{
$result = $this->comparisonService->clearComparison($this->getUser());
return $this->json($result);
}
#[Route('/api/comparison/count', name: 'api_comparison_count', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function getComparisonCount(): JsonResponse
{
$count = $this->comparisonService->getComparisonCount($this->getUser());
return $this->json([
'success' => true,
'count' => $count
]);
}
#[Route('/api/comparison/status/{id}', name: 'api_comparison_status', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function getComparisonStatus(int $id): JsonResponse
{
$product = $this->entityManager->getRepository(Product::class)->find($id);
if (!$product) {
return $this->json([
'success' => false,
'message' => 'Produit non trouvé'
], 404);
}
$isInComparison = $this->comparisonService->isInComparison($this->getUser(), $product);
return $this->json([
'success' => true,
'inComparison' => $isInComparison
]);
}
#[Route('/comparison', name: 'product_comparison', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function comparisonPage(): Response
{
$comparisonData = $this->comparisonService->getComparisonData($this->getUser());
return $this->render('product/comparison.html.twig', [
'comparisonData' => $comparisonData,
'current_menu' => 'comparison'
]);
}
#[Route('/api/comparison/data', name: 'api_comparison_data', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function getComparisonData(): JsonResponse
{
$comparisonData = $this->comparisonService->getComparisonData($this->getUser());
return $this->json([
'success' => true,
'data' => $comparisonData
]);
}
#[Route(path: '/logout', name: 'app_logout')]
public function logout(): void
{
throw new LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
#[Route('account/points', name: 'account_points', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function indexPoint(Request $request): Response
{
$user = $this->getUser();
$page = max(1, (int)$request->query->get('page', 1));
$limit = 20;
$offset = ($page - 1) * $limit;
$stats = $this->pointService->getStats($user);
$transactions = $this->pointService->getTransactionHistory($user, $limit, $offset);
$totalTransactions = count($transactions);
return $this->render('account/points/index.html.twig', [
'stats' => $stats,
'transactions' => $transactions,
'currentPage' => $page,
'totalPages' => ceil($totalTransactions / $limit),
'hasMore' => $totalTransactions >= $limit,
]);
}
#[Route('/account/points/convert', name: 'account_points_convert', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function convert(Request $request): Response
{
$user = $this->getUser();
$userPoints = $this->pointService->getUserPoints($user);
if ($request->isMethod('POST')) {
$points = (int)$request->request->get('points');
$recipientEmail = $request->request->get('recipient_email');
$recipientName = $request->request->get('recipient_name');
if ($points < 100) {
$this->addFlash('error', 'Le minimum est de 100 points (1 HTG)');
return $this->redirectToRoute('ui_account_points_convert');
}
if ($userPoints->getBalance() < $points) {
$this->addFlash('error', 'Solde de points insuffisant');
return $this->redirectToRoute('ui_account_points_convert');
}
try {
$giftCard = $this->pointService->convertPointsToGiftCard(
$user,
$points,
$recipientEmail ?: null,
$recipientName ?: null
);
$this->addFlash('success', "Carte cadeau de {$giftCard->getInitialAmount()} HTG créée avec succès ! Code: {$giftCard->getCode()}");
return $this->redirectToRoute('ui_account_gift_cards');
} catch (\Exception $e) {
$this->addFlash('error', $e->getMessage());
}
}
$stats = $this->pointService->getStats($user);
return $this->render('account/points/convert.html.twig', [
'stats' => $stats,
]);
}
}