<?php
namespace App\Controller;
use App\Entity\Address;
use App\Entity\Brand;
use App\Entity\Cart;
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;
#[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): Response
{
// Récupérer les données pour la page d'accueil
// Produits en vedette pour le caroussel de bannière
$featuredProducts = $entityManager->getRepository(Product::class)->findBy([
'isFeatured' => true,
'isActive' => true
], ['publishedAt' => 'DESC'], 10);
$latestProducts = $entityManager->getRepository(Product::class)->findBy([
'isActive' => true
], ['publishedAt' => 'DESC'], 12);
// Limiter à 8 catégories les plus importantes (par quantité de produits)
$allCategories = $entityManager->getRepository(Category::class)->findBy([
'isActive' => true
], ['name' => 'ASC']);
// Trier par nombre de produits
usort($allCategories, function($a, $b) {
$countA = $a->getProducts()->count();
$countB = $b->getProducts()->count();
return $countB <=> $countA;
});
$categories = array_slice($allCategories, 0, 8);
// Limiter à 6 catégories de boutiques
$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, 12);
} else {
$recommendedProducts = $entityManager->getRepository(Product::class)->findBy(
['isActive' => true],
['viewCount' => 'DESC'],
12
);
}
// Statistiques pour le dashboard
$stats = [
'totalProducts' => $entityManager->getRepository(Product::class)->count(['isActive' => true]),
'totalShops' => $entityManager->getRepository(Shop::class)->count(['isActive' => true]),
'totalCategories' => $entityManager->getRepository(Category::class)->count(['isActive' => true]),
];
// Toutes les catégories pour le bouton "Voir tout"
$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',
'featuredProducts' => $featuredProducts,
'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 = 12;
$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');
$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 = $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']);
// Construire la requête pour les produits
$qb = $em->getRepository(Product::class)->createQueryBuilder('p')
->where('p.isActive = :active')
->setParameter('active', true);
// Filtre par catégorie
if ($categorySlug) {
$qb->andWhere('p.category = :category')
->setParameter('category', $em->getRepository(Category::class)->findOneBy(['slug' => $categorySlug]));
}
// Recherche par mot-clé
if ($searchQuery) {
$qb->andWhere('p.name LIKE :searchQuery OR p.description LIKE :searchQuery')
->setParameter('searchQuery', '%' . $searchQuery . '%');
}
// 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 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
$productsPerPage = 12;
$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 les deals de la semaine (produits avec un prix barré, limités à 9)
$dealsQb = $em->getRepository(Product::class)->createQueryBuilder('p')
->where('p.isActive = :active')
->andWhere('p.compareAtPrice > p.price')
->setParameter('active', true)
->orderBy('p.publishedAt', 'DESC')
->setMaxResults(9);
$dealsOfTheWeek = $dealsQb->getQuery()->getResult();
// S'il n'y a pas assez de deals, on complète avec des produits vedettes
if (count($dealsOfTheWeek) < 9) {
$needed = 9 - count($dealsOfTheWeek);
$featuredQb = $em->getRepository(Product::class)->createQueryBuilder('p')
->where('p.isActive = :active')
->andWhere('p.isFeatured = :featured')
->setParameter('active', true)
->setParameter('featured', true);
if (count($dealsOfTheWeek) > 0) {
$existingIds = array_map(function($p) { return $p->getId(); }, $dealsOfTheWeek);
$featuredQb->andWhere('p.id NOT IN (:existingIds)')
->setParameter('existingIds', $existingIds);
}
$featuredQb->setMaxResults($needed);
$extraDeals = $featuredQb->getQuery()->getResult();
$dealsOfTheWeek = array_merge($dealsOfTheWeek, $extraDeals);
}
return $this->render('home/listing.html.twig', [
'current_menu' => 'listing',
'products' => $products,
'categories' => $categories,
'brands' => $brands,
'currentCategory' => $categorySlug,
'currentBrand' => $brandSlug,
'currentSort' => $sortBy,
'currentPage' => $page,
'totalPages' => $totalPages,
'totalProducts' => $totalProducts,
'priceMin' => $priceMin,
'priceMax' => $priceMax,
'searchQuery' => $searchQuery,
'dealsOfTheWeek' => $dealsOfTheWeek,
]);
}
#[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');
$sortBy = $request->query->get('sort', 'newest');
$priceMin = $request->query->get('price_min');
$priceMax = $request->query->get('price_max');
$page = $request->query->getInt('page', 1);
// 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');
$searchQuery = $request->query->get('q');
// Construire la requête pour les produits
$qb = $em->getRepository(Product::class)->createQueryBuilder('p')
->where('p.isActive = :active')
->setParameter('active', true);
// Filtre par catégorie
if ($categorySlug) {
$qb->andWhere('p.category = :category')
->setParameter('category', $em->getRepository(Category::class)->findOneBy(['slug' => $categorySlug]));
}
// Recherche par mot-clé
if ($searchQuery) {
$qb->andWhere('p.name LIKE :searchQuery OR p.description LIKE :searchQuery')
->setParameter('searchQuery', '%' . $searchQuery . '%');
}
// 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 base)
if ($color) {
$qb->andWhere('p.color = :color')
->setParameter('color', $color);
}
if ($size) {
$qb->andWhere('p.size = :size')
->setParameter('size', $size);
}
if ($material) {
$qb->andWhere('p.material = :material')
->setParameter('material', $material);
}
if ($condition) {
$qb->andWhere('p.itemCondition = :condition')
->setParameter('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
$productsPerPage = 12;
$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 les marques disponibles pour cette catégorie
$availableBrands = [];
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
];
}
}
}
// Récupérer les boutiques disponibles
$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']
];
}
}
// 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,
'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')
->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();
// Couleurs
$color = $product->getColor() ?? ($productAttributes['color'] ?? null);
if ($color) {
if (!isset($attributes['colors'][$color])) $attributes['colors'][$color] = 0;
$attributes['colors'][$color]++;
}
// Tailles
$size = $product->getSize() ?? ($productAttributes['size'] ?? null);
if ($size) {
if (!isset($attributes['sizes'][$size])) $attributes['sizes'][$size] = 0;
$attributes['sizes'][$size]++;
}
// Matériaux
$material = $product->getMaterial() ?? ($productAttributes['material'] ?? null);
if ($material) {
if (!isset($attributes['materials'][$material])) $attributes['materials'][$material] = 0;
$attributes['materials'][$material]++;
}
// Conditions
$condition = $product->getItemCondition() ?? ($productAttributes['condition'] ?? null);
if ($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);
} 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();
return new JsonResponse(['ok' => true, 'totalQty' => $totalQty]);
}
#[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();
// 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);
}
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);
} else {
$cartItem = new CartItem();
$cartItem->setProduct($product);
$cartItem->setQuantity($qty);
$cart->addItem($cartItem);
$em->persist($cartItem);
}
}
$em->flush();
$subtotal = $cart->getTotalAmount();
$totalQty = $cart->getItemCount();
return new JsonResponse([
'ok' => true,
'subtotal' => $subtotal,
'totalQty' => $totalQty
]);
}
#[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();
$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/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
*/
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);
}
// 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);
}
// 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(): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('ui_app_login');
}
return $this->render('account/account.html.twig');
}
#[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('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);
return $this->render('account/orders.html.twig', [
'orders' => $orders,
'current_page' => $page,
'total_pages' => $totalPages,
'active' => 'orders'
]);
}
#[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');
}
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);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$amount = (float)$giftCard->getInitialAmount();
$recipientEmail = $giftCard->getRecipientEmail();
$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(),
'current_menu' => 'giftcard'
]);
}
}
// Créer la carte cadeau
$newGiftCard = $giftCardService->createGiftCard(
$amount,
$user,
$recipient, // recipient sera défini si email correspond à un utilisateur
$recipientEmail,
$giftCard->getRecipientName(),
$giftCard->getMessage()
);
$this->addFlash('success', 'Carte cadeau créée avec succès ! Code: ' . $newGiftCard->getCode());
// 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(),
'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'];
$currentUser = $this->getUser();
// 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() !== $currentUser->getId()) {
return $this->json([
'success' => 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() !== $currentUser->getId()) {
return $this->json([
'success' => false,
'message' => 'Cette carte cadeau ne vous appartient pas'
], 403);
}
}
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']) ? strtoupper(trim($data['giftCardCode'])) : null;
// 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;
// Calculer la réduction carte cadeau
$giftCardDiscount = 0;
if ($giftCard) {
$balance = (float) $giftCard->getBalance();
$giftCardDiscount = min($balance, $totalBeforeTax);
}
$totalBeforeTaxAfterDiscount = $totalBeforeTax - $giftCardDiscount;
$taxAmount = $totalBeforeTaxAfterDiscount * 0.01;
$totalAmount = $totalBeforeTaxAfterDiscount + $taxAmount;
// Vérifier que le total est bien <= 0
if ($totalAmount > 0) {
return new JsonResponse([
'ok' => false,
'message' => 'Le montant restant doit être inférieur ou égal à zéro pour utiliser cette méthode'
], 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);
}
// 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')) {
// TODO: Gérer la mise à jour des paramètres
$this->addFlash('success', 'Paramètres mis à jour avec succès.');
return $this->redirectToRoute('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');
}
$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();
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(),
]);
}
#[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();
}
return $this->redirectToRoute('ui_account_address', [], Response::HTTP_SEE_OTHER);
}
#[Route('/register', name: 'app_register')]
public function register(
Request $request,
UserPasswordHasherInterface $userPasswordHasher,
EntityManagerInterface $entityManager
): 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.com', 'Verification de votre adresse email'))
->to($user->getEmail())
->subject('Please Confirm your Email')
->htmlTemplate('registration/confirmation_email.html.twig')
);
// do anything else you need here, like send an email
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.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')]
public function shopShow(string $slug, ProductRepository $productRepository, ViewTrackingService $viewTrackingService, ShopFollowService $shopFollowService, EntityManagerInterface $em): Response
{
$shop = $em->getRepository(Shop::class)->findOneBy(['slug' => $slug]);
if (!$shop) {
throw $this->createNotFoundException('Boutique non trouvée');
}
// 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(Shop $shop, ShopFollowService $shopFollowService): JsonResponse
{
$user = $this->getUser();
if (!$user) {
return $this->json(['success' => false, 'message' => 'Vous devez être connecté pour suivre une boutique'], 401);
}
$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(Shop $shop, ShopFollowService $shopFollowService): JsonResponse
{
$user = $this->getUser();
if (!$user) {
return $this->json(['success' => false, 'message' => 'Vous devez être connecté pour ne plus suivre une boutique'], 401);
}
$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(Shop $shop, ShopFollowService $shopFollowService): JsonResponse
{
$user = $this->getUser();
if (!$user) {
return $this->json(['success' => false, 'message' => 'Vous devez être connecté pour suivre une boutique'], 401);
}
$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(Shop $shop, ShopFollowService $shopFollowService): JsonResponse
{
$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'])]
public function addToComparison(int $id, Request $request): JsonResponse
{
$product = $this->entityManager->getRepository(Product::class)->find($id);
if (!$product) {
return $this->json([
'success' => false,
'message' => 'Produit non trouvé'
], 404);
}
$currentUser = $this->getUser();
$user = $currentUser instanceof User ? $currentUser : null;
$result = $this->comparisonService->addToComparison($user, $product, $request->getSession());
return $this->json($result);
}
#[Route('/api/comparison/remove/{id}', name: 'api_comparison_remove', methods: ['POST'])]
public function removeFromComparison(int $id, Request $request): JsonResponse
{
$product = $this->entityManager->getRepository(Product::class)->find($id);
if (!$product) {
return $this->json([
'success' => false,
'message' => 'Produit non trouvé'
], 404);
}
$currentUser = $this->getUser();
$user = $currentUser instanceof User ? $currentUser : null;
$result = $this->comparisonService->removeFromComparison($user, $product, $request->getSession());
return $this->json($result);
}
#[Route('/api/comparison/clear', name: 'api_comparison_clear', methods: ['POST'])]
public function clearComparison(Request $request): JsonResponse
{
$currentUser = $this->getUser();
$user = $currentUser instanceof User ? $currentUser : null;
$result = $this->comparisonService->clearComparison($user, $request->getSession());
return $this->json($result);
}
#[Route('/api/comparison/count', name: 'api_comparison_count', methods: ['GET'])]
public function getComparisonCount(Request $request): JsonResponse
{
$currentUser = $this->getUser();
$user = $currentUser instanceof User ? $currentUser : null;
$count = $this->comparisonService->getComparisonCount($user, $request->getSession());
return $this->json([
'success' => true,
'count' => $count
]);
}
#[Route('/api/comparison/status/{id}', name: 'api_comparison_status', methods: ['GET'])]
public function getComparisonStatus(int $id, Request $request): JsonResponse
{
$product = $this->entityManager->getRepository(Product::class)->find($id);
if (!$product) {
return $this->json([
'success' => false,
'message' => 'Produit non trouvé'
], 404);
}
$currentUser = $this->getUser();
$user = $currentUser instanceof User ? $currentUser : null;
$isInComparison = $this->comparisonService->isInComparison($user, $product, $request->getSession());
return $this->json([
'success' => true,
'inComparison' => $isInComparison
]);
}
#[Route('/comparison', name: 'product_comparison', methods: ['GET'])]
public function comparisonPage(Request $request): Response
{
$currentUser = $this->getUser();
$user = $currentUser instanceof User ? $currentUser : null;
$shareToken = $request->query->get('share');
$comparisonData = $this->comparisonService->getComparisonData($user, $request->getSession(), is_string($shareToken) ? $shareToken : null);
$comparisonShareUrl = null;
if (!empty($comparisonData['shareToken'])) {
$comparisonShareUrl = $this->generateUrl('ui_product_comparison', [], UrlGeneratorInterface::ABSOLUTE_URL)
. '?share=' . urlencode((string) $comparisonData['shareToken']);
}
return $this->render('product/comparison.html.twig', [
'comparisonData' => $comparisonData,
'comparisonShareUrl' => $comparisonShareUrl,
'current_menu' => 'comparison'
]);
}
#[Route('/api/comparison/data', name: 'api_comparison_data', methods: ['GET'])]
public function getComparisonData(Request $request): JsonResponse
{
$currentUser = $this->getUser();
$user = $currentUser instanceof User ? $currentUser : null;
$comparisonData = $this->comparisonService->getComparisonData($user, $request->getSession());
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,
]);
}
}