Pack d'articles ajoutés au panier en un clic

This post is available in english

Dans cet article on va créer une action qui va ajouter une liste de produits au panier. On va également créer la gestion des produits par pack dans le dashboard.

On va commencer par créer une entité qu'on pourra gérer dans le dashboard et qui nous permettra de sélectionner les produits qu'on veut mettre dans le pack.

src/Entity/ProductBundle.php

<?php

namespace App\Entity;

use App\Entity\Product\Product;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="App\Repository\ProductBundleRepository")
 */
class ProductBundle implements ProductBundleInterface
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @var string|null
     *
     * @ORM\Column(type="string")
     */
    private $name;

    /**
     * @ORM\ManyToMany(targetEntity="App\Entity\Product\Product")
     */
    private $products;

    public function __construct()
    {
        $this->products = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getName(): ?string
    {
        return $this->name;
    }

    /**
     * @param string $name
     */
    public function setName(?string $name): void
    {
        $this->name = $name;
    }

    public function countProducts(): int
    {
        return $this->products->count();
    }

    /**
     * @return Collection|Product[]
     */
    public function getProducts(): Collection
    {
        return $this->products;
    }

    public function addProduct(Product $product): self
    {
        if (!$this->products->contains($product)) {
            $this->products[] = $product;
        }

        return $this;
    }

    public function removeProduct(Product $product): self
    {
        if ($this->products->contains($product)) {
            $this->products->removeElement($product);
        }

        return $this;
    }
}

src/Repository/ProductBundleRepository.php

<?php

namespace App\Repository;

use App\Entity\ProductBundle;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @method ProductBundle|null find($id, $lockMode = null, $lockVersion = null)
 * @method ProductBundle|null findOneBy(array $criteria, array $orderBy = null)
 * @method ProductBundle[]    findAll()
 * @method ProductBundle[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class ProductBundleRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, ProductBundle::class);
    }

    // /**
    //  * @return ProductBundle[] Returns an array of ProductBundle objects
    //  */
    /*
    public function findByExampleField($value)
    {
        return $this->createQueryBuilder('b')
            ->andWhere('b.exampleField = :val')
            ->setParameter('val', $value)
            ->orderBy('b.id', 'ASC')
            ->setMaxResults(10)
            ->getQuery()
            ->getResult()
        ;
    }
    */

    /*
    public function findOneBySomeField($value): ?ProductBundle
    {
        return $this->createQueryBuilder('b')
            ->andWhere('b.exampleField = :val')
            ->setParameter('val', $value)
            ->getQuery()
            ->getOneOrNullResult()
        ;
    }
    */
}

src/Entity/ProductBundleInterface.php

<?php

namespace App\Entity;

use App\Entity\Product\Product;
use Doctrine\Common\Collections\Collection;
use Sylius\Component\Resource\Model\ResourceInterface;

interface ProductBundleInterface extends ResourceInterface
{
    /**
     * @return string
     */
    public function getName(): ?string;

    /**
     * @param string $name
     */
    public function setName(?string $name): void;

    public function countProducts(): int;

    /**
     * @return Collection|Product[]
     */
    public function getProducts(): Collection;

    public function addProduct(Product $product): \App\Entity\ProductBundle;

    public function removeProduct(Product $product): \App\Entity\ProductBundle;
}

On déclare cette ressource dans config/packages/resources.yaml

sylius_resource:
    resources:
        app.product_bundle:
            classes:
                model: App\Entity\ProductBundle

Sa grille dans config/packages/grids.yaml

sylius_grid:
    grids:
        app_admin_product_bundle:
            driver:
                name: doctrine/orm
                options:
                    class: App\Entity\ProductBundle
            fields:
                name:
                    type: string
            actions:
                main:
                    create:
                        type: create
                item:
                    update:
                        type: update
                    delete:
                        type: delete

Ainsi que sa route dans config/routes.yaml

app_product_bundle:
    resource: |
        alias: app.product_bundle
        section: admin
        templates: SyliusAdminBundle:Crud
        grid: app_admin_product_bundle
        redirect: index
    type: sylius.resource
    prefix: admin

On met à jour la base de données

php bin/console doctrine:migration:diff

php bin/console doctrine:migration:migrate

Maintenant quand on se rend sur le dashboard à l'adresse admin/product-bundles/ et qu'on clique sur Createon à ce résultat :

Image Title

On voudrait avoir les titres des Produits, on va donc créer notre propre formulaire et on va en choisir les produits avec une liste de checkbox

src/Form/Type/BundleType.php

<?php

namespace App\Form\Type;

use App\Entity\Product\Product;
use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;

class BundleType extends AbstractResourceType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, ['label' => 'sylius.ui.name'])
            ->add('products', EntityType::class, array(
                'class' => Product::class,
                'choice_label' => 'name',
                'label'        => 'Product',
                'expanded'     => true,
                'multiple'     => true,
            ))
        ;
    }
}

On déclare notre nouveau formulaire en tant que service dans config/services.yaml

services:
    app.form.type.bundle:
        class: App\Form\Type\BundleType
        tags:
            - { name: form.type}
        arguments: ['App\Entity\ProductBundle']

Et on rajoute dans notre ressource config/packages/resources.yaml le chemin de notre nouveau formulaire

sylius_resource:
    resources:
        app.product_bundle:
            classes:
                model: App\Entity\ProductBundle
                form: App\Form\Type\BundleType # on rajoute cette ligne

Si on rafraichit la page admin/product-bundles/new et le résultat est le suivant :

Image Title

On choisit nos produits, puis on met un titre à notre pack et on clique sur Create, ici je vais créer le pack "Special"

Image Title

Maintenant on va afficher ce pack dans la homepage,

src/Controller/ProductBundleController.php

<?php

namespace App\Controller;

use App\Repository\ProductBundleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class ProductBundleController extends AbstractController
{

    public function index($bundleName, ProductBundleRepository $bundles)
    {
        $bundle = $bundles->findOneBy(array('name' => $bundleName));

        return $this->render('ProductBundle/_products.html.twig',
            array('bundle' => $bundle)
        );
    }
}

On crée la vue qui est retourné par le controller templates/ProductBundle/_products.html.twig

<div class="ui very padded center aligned inverted segment">
    <h2 class="ui center aligned huge header">Bundle product</h2>

    <div class="ui three odd doubling cards segment centered">
        {% for product in bundle.products %}
            {% include '@SyliusShop/Product/_box.html.twig' %}
        {% endfor %}
    </div>
</div>

On ajoute sa route dans routes.yaml

config/routes.yaml

app_page_product_bundle:
    path: /bundle
    methods: [GET]
    controller: App\Controller\ProductBundleController::index

Le retour de notre action index de notre controller sera rendu dans un template, on crée donc celui-ci templates/ProductBundle/_index.html.twig en faisant attention de mettre le nom de notre pack

{{ render(controller('App\\Controller\\ProductBundleController::index', {'bundleName' : 'Special'})) }}

On ajoute notre template dans le rendu par event de sylius config/packages/sylius_ui.yaml

sylius_ui:
    events:
        sylius.shop.homepage:
            blocks:
                bundle:
                    template: "ProductBundle/_index.html.twig"
                    priority: 70

priority indique l'ordre de rendu des templates appelés. On peut les trouver en inspectant le rendu HTML :

Image Title

Nous avons maintenant ce résultat sur la page d'accueil

Image Title

Il nous reste à créer une action qui va récupérer les produits dans la boucle. Pour l'exemple nous prendrons leur première variante (c'est à dire, pour un tee-shirt par exemple, la première taille qui va sortir donc la taille S)

src/Controller/ProductBundleAction.php

<?php

namespace App\Controller;

use App\Repository\ProductBundleRepository;
use Doctrine\Common\Persistence\ObjectManager;
use Sylius\Bundle\ResourceBundle\Controller\ResourceController;
use Sylius\Component\Core\Context\ShopperContextInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\OrderItemInterface;
use Sylius\Component\Core\Model\ProductVariantInterface;
use Sylius\Component\Core\Repository\ProductVariantRepositoryInterface;
use Sylius\Component\Order\Context\CartContextInterface;
use Sylius\Component\Order\Modifier\OrderItemQuantityModifierInterface;
use Sylius\Component\Resource\Factory\FactoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

class ProductBundleAction extends ResourceController
{
    /** @var ProductVariantRepositoryInterface */
    private $productVariantRepository;

    /** @var FactoryInterface */
    private $orderItemFactory;

    /** @var OrderItemQuantityModifierInterface */
    private $orderItemQuantityModifier;

    /** @var ShopperContextInterface */
    private $shopperContext;

    /** @var ObjectManager */
    private $orderManager;

    /** @var UrlGeneratorInterface */
    private $router;

    /**
     * QuickCheckoutAction constructor.
     * @param ProductVariantRepositoryInterface $productVariantRepository
     * @param FactoryInterface $orderFactory
     * @param FactoryInterface $orderItemFactory
     * @param OrderItemQuantityModifierInterface $orderItemQuantityModifier
     * @param ShopperContextInterface $shopperContext
     * @param ObjectManager $orderManager
     * @param UrlGeneratorInterface $router
     */
    public function __construct(ProductVariantRepositoryInterface $productVariantRepository,
                                FactoryInterface $orderItemFactory,
                                OrderItemQuantityModifierInterface $orderItemQuantityModifier,
                                ShopperContextInterface $shopperContext,
                                ObjectManager $orderManager,
                                UrlGeneratorInterface $router)
    {
        $this->productVariantRepository = $productVariantRepository;
        $this->orderItemFactory = $orderItemFactory;
        $this->orderItemQuantityModifier = $orderItemQuantityModifier;
        $this->shopperContext = $shopperContext;
        $this->orderManager = $orderManager;
        $this->router = $router;
    }

    public function __invoke(Request $request, ProductBundleRepository $productBundleRepository): Response
    {
        $bundleName = $request->attributes->get('name');
        $bundle = $productBundleRepository->findOneBy(array('name' => $bundleName));

        /** @var OrderInterface $order */
        $order = $this->getCurrentCart();

        $channel = $this->shopperContext->getChannel();
        foreach ($bundle->getProducts() as $product)
        {
            $productId = (int) $product->getVariants()->first()->getId();
            /** @var ProductVariantInterface $variant */
            $variant = $this->productVariantRepository->find($productId);

            /** @var OrderItemInterface $orderItem */
            $orderItem = $this->orderItemFactory->createNew();
            $variant->setShippingRequired(false);
            $orderItem->setVariant($variant);
            $price = $variant->getChannelPricingForChannel($channel)->getPrice();
            $orderItem->setUnitPrice($price);

            $this->orderItemQuantityModifier->modify($orderItem, 1);

            $order->addItem($orderItem);
        }

        $order->setChannel($channel);
        $order->setLocaleCode($this->shopperContext->getLocaleCode());
        $order->setCurrencyCode($this->shopperContext->getCurrencyCode());

        $this->orderManager->persist($order);
        $this->orderManager->flush();

        $cartPage = $this->router->generate('sylius_shop_cart_summary');
        return new RedirectResponse($cartPage);
    }

    protected function getCurrentCart(): \Sylius\Component\Order\Model\OrderInterface
    {
        return $this->getContext()->getCart();
    }

    protected function getContext(): CartContextInterface
    {
        return $this->get('sylius.context.cart');
    }
}

On le déclare en tant que service config/services.yaml

services:
    App\Controller\ProductBundleAction:
        arguments:
            - '@sylius.repository.product_variant'
            - '@sylius.factory.order_item'
            - '@sylius.order_item_quantity_modifier'
            - '@sylius.context.shopper'
            - '@sylius.manager.order'
            - '@router'
        public: true

On définit sa route config/routes.yaml

app_add_bundle:
    path: /bundle/{name}
    methods: [POST]
    defaults:
        _controller: App\Controller\ProductBundleAction

On rajoute notre formulaire à notre template templates/ProductBundle/_products.html.twig

<div class="ui very padded center aligned inverted segment">
    <h2 class="ui center aligned huge header">Bundle product</h2>

    <div class="ui three odd doubling cards segment centered">
        {% for product in bundle.products %}
            {% include '@SyliusShop/Product/_box.html.twig' %}
        {% endfor %}
    </div>

    <form action="{{ path('app_add_bundle', {name: 'Special' }) }}" method="post">
        <button type="submit" class="ui huge yellow icon labeled button"><i class="bolt icon"></i> Buy this pack</button>
    </form>
</div>

On à maintenant le bouton "Buy this pack"

Image Title

Maintenant quand on clique dessus on est redirigé vers le panier avec les produits ajoutés

Image Title