Sylius: [RFC] Cart consolidation/retrieval across logins

Created on 11 Feb 2016  路  13Comments  路  Source: Sylius/Sylius

Hi,

We need to implement abandoned cart retrieval upon login. This is really important feature for us (and fairly standard on decent ecommerce stores), so that customers can return to the site, possibly on another device and continue their journey.

The logic will be that upon login the contents of your most recent abandoned cart will be merged (availability permitting) with your current session cart. The previous abandoned cart is then deleted and you maintain the resulting products going forwards in cart.

We also have a policy of retaining abandoned cart for up to 2 weeks, at which point they are usually deleted for storage reasons. We might potentially change this in future, but it seems reasonable for now.

Has anyone done this or got any ideas on the easiest way to implement it?

RFC Stale

Most helpful comment

Thanks a lot @psihius. The default behaviour was preventing us from enabling user login on our website.

From our point of view, on a e-commerce website, there should never be any scenario leading to users losing products in their carts. This should be the standard behaviour in Sylius, or it should at least be possible to activate this through some config.

All 13 comments

Quick thoughts:

  • I would like to track the cart in session only for not logged in users
  • Signed in users would have a single cart retrieved by their id, thus having single cart across all devices

We need to replace CartProvider with CompositeCartProvider to allow different cart providers with priorities:

  • First one returns latest cart for logged in customer (findOneBy(customer, $state = 'cart'))
  • Second one checks the session
  • Third one always returns new cart

Thoughts?

Thanks for the feedback!

What about the following:

  • You are not logged in and get a cart assigned through session
  • You add an item to your cart.
  • You then login.
  • What happens now? You already have a cart in session, would the CompositeCartProvider go back and pull from a previous login overwriting your session? or merging with your session? or not overwrite at all if your session cart has items in it?

I think there might need to be some Strategy options here so you can configure behaviour because retailers can have very different opinions on this.

We had a look at a few top retail sites and Amazon for one offers merging of session with previous login cart after logging in. Others had varying behaviour but most offered some form of cart retrieval from a previous login.

@adamelso may well end up having a look at this so we might make up a PR based on your outline above and see how it goes... Any further comments & ideas are very welcome though.

We added this feature request on our roadmap a few days ago too :)

We plan to go for the simple implementation of merging the two carts: the one from the Visitor session and the one from the User persistence - no other alternatives.

Though our scenario building phase is still in progress, we don't think there's much business values in the one described below:

Given I am logged in as "John Doe"
And I add product "A" to basket
And I logout
And I add product "B" to basket
When I log in as "John Doe"
And I go to cart I see product "A"

Q: why should product B be removed from my cart if I explicitly added it before logging in? think about sessions that expire: would you like to ask you potential buyers to add to cart twice in 1h if your session lasts only for 30 min. for security reasons?

We plan to implement it like this:

Given I am logged in as "John Doe"
And I add product "A" to basket
And I logout
And I add product "B" to basket
When I log in as "John Doe"
And I go to cart I see the products:
|title|
|product A|
|product B|

Notes: product A are stored in session and in database. We want to use the database to be able to trigger "abandoned cart" transactional emails.

We want to ship this feature in Late March, early April. @peteward how about you?

@gabiudrescu yes, agreed that's the least likely scenario, but this is possible (and implemented by some retailers):

Given I am logged in as "John Doe"
And I add product "A" to basket
And I logout
And I add product "B" to basket
When I log in as "John Doe"
And I go to cart I see product "B"

Because some people consider that if you have a current and active session, that you override the previous one. Although we won't use this strategy,

One scenario that we want to cope with is:

Given I am logged in as "John Doe"
And I add product "A" to basket
And I add product "B" to basket
And I logout
And I add product "B" to basket
And I add product "C" to basket
When I log in as "John Doe"
And I go to cart I see the products with the following quantities:
|title     | quantity |
|product A | 1        |
|product B | 1        |
|product C | 1        |

A common behaviour is a customer putting something in their cart on mobile, then returning home and adding it again on their desktop to buy it. They go to checkout and login and find they have a quantity of 2 in their basket. Sometimes they don't even notice and buy 2 accidentally...

So we are looking at a merge-no-duplicates strategy.

Your time-scales sound similar to ours, I would perhaps like this a bit sooner but we'll see.

100% agree with the last behavior - you definitely don't want to item x 2 quantity in you basket upon cart merge.

Will this be implemented soon ? I guess this is quite important for an e-commerce website.

Any news about this feature?

@psihius you mind if I add your solution in a PR?

It's barely a solution at all :)

Here's my class I use, just compare the code with the base class - it's trivial

<?php

/*
 * This file is part of the Sylius package.
 *
 * (c) Pawe艂 J臋drzejewski
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

declare(strict_types=1);

namespace AppBundle\EventListener;

use Doctrine\Common\Persistence\ObjectManager;
use Sylius\Bundle\UserBundle\Event\UserEvent;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\Order\Context\CartContextInterface;
use Sylius\Component\Order\Context\CartNotFoundException;
use Sylius\Component\Resource\Exception\UnexpectedTypeException;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;

/**
 * @author Micha艂 Marcinkowski <[email protected]>
 */
final class CartBlamerListener
{
    /**
     * @var ObjectManager
     */
    private $cartManager;

    /**
     * @var CartContextInterface
     */
    private $cartContext;

    /**
     * @var CartContextInterface
     */
    private $sessionCartContext;

    /**
     * @param ObjectManager        $cartManager
     * @param CartContextInterface $cartContext
     * @param CartContextInterface $sessionCartContext
     */
    public function __construct(
        ObjectManager $cartManager,
        CartContextInterface $cartContext,
        CartContextInterface $sessionCartContext
    ) {
        $this->cartManager        = $cartManager;
        $this->cartContext        = $cartContext;
        $this->sessionCartContext = $sessionCartContext;
    }

    /**
     * @param UserEvent $userEvent
     */
    public function onImplicitLogin(UserEvent $userEvent): void
    {
        $user = $userEvent->getUser();
        if (!$user instanceof ShopUserInterface) {
            return;
        }

        $this->blame($user);
    }

    /**
     * @param InteractiveLoginEvent $interactiveLoginEvent
     */
    public function onInteractiveLogin(InteractiveLoginEvent $interactiveLoginEvent): void
    {
        $user = $interactiveLoginEvent->getAuthenticationToken()->getUser();
        if (!$user instanceof ShopUserInterface) {
            return;
        }

        $this->blame($user);
    }

    /**
     * @param ShopUserInterface $user
     */
    private function blame(ShopUserInterface $user): void
    {
        $cart = $this->getCart();
        if (null === $cart) {
            return;
        }

        try {
            $sessionCart = $this->sessionCartContext->getCart();
        } catch (CartNotFoundException $e) {
            $sessionCart = null;
        }
        if ($sessionCart !== null && $sessionCart->getId() !== $cart->getId()) {
            foreach ($sessionCart->getItems() as $item) {
                $cart->addItem($item);
            }
            $this->cartManager->remove($sessionCart);
            $this->cartManager->persist($sessionCart);
        } else {
            $cart->setCustomer($user->getCustomer());
        }
        $this->cartManager->persist($cart);
        $this->cartManager->flush();
    }

    /**
     * @return OrderInterface|null
     *
     * @throws UnexpectedTypeException
     */
    private function getCart(): ?OrderInterface
    {
        try {
            $cart = $this->cartContext->getCart();
        } catch (CartNotFoundException $exception) {
            return null;
        }

        if (!$cart instanceof OrderInterface) {
            throw new UnexpectedTypeException($cart, OrderInterface::class);
        }

        return $cart;
    }
}

Thanks a lot @psihius. The default behaviour was preventing us from enabling user login on our website.

From our point of view, on a e-commerce website, there should never be any scenario leading to users losing products in their carts. This should be the standard behaviour in Sylius, or it should at least be possible to activate this through some config.

This issue has been automatically marked as stale because it has not had any recent activity. It will be closed in a week if no further activity occurs. Thank you for your contributions.

I have no idea why this was closed by stalebot and why this isn't yet tackled in Sylius by default.

@CoderMaggie can this task get some product-love, please?

We've implemented the CartBlamerListener by @psihius (https://github.com/Sylius/Sylius/issues/4117#issuecomment-329153002) in our Sylius 1.6 installation but had to also DI the OrderModifierInterface and instead of:

$cart->addItem($item);

we did:

$this->orderModifier->addToOrder($cart, clone $item);

This is the definition in config/services.yaml:

app.listener.cart_blamer:
    class: App\EventListener\CartBlamerListener
    decorates: sylius.listener.cart_blamer
    arguments:
        - '@sylius.manager.order'
        - '@sylius.context.cart'
        - '@sylius.context.cart.session_and_channel_based'
        - '@sylius.order_modifier'
Was this page helpful?
0 / 5 - 0 ratings

Related issues

inssein picture inssein  路  3Comments

hmonglee picture hmonglee  路  3Comments

mikemix picture mikemix  路  3Comments

crbelaus picture crbelaus  路  3Comments

igormukhingmailcom picture igormukhingmailcom  路  3Comments