<?php declare(strict_types=1);
namespace MkxBetterVariants\Subscriber;
use MkxBetterVariants\Enumeration\CustomField as Enum;
use Shopware\Core\Content\Cms\Aggregate\CmsSlot\CmsSlotEntity;
use Shopware\Core\Content\Cms\SalesChannel\Struct\ProductBoxStruct;
use Shopware\Core\Content\Cms\SalesChannel\Struct\ProductSliderStruct;
use Shopware\Core\Content\Product\Events\ProductCrossSellingCriteriaEvent;
use Shopware\Core\Content\Product\Events\ProductCrossSellingIdsCriteriaEvent;
use Shopware\Core\Content\Product\Events\ProductCrossSellingsLoadedEvent;
use Shopware\Core\Content\Product\Events\ProductCrossSellingStreamCriteriaEvent;
use Shopware\Core\Content\Product\Events\ProductListingResultEvent;
use Shopware\Core\Content\Product\Events\ProductSearchResultEvent;
use Shopware\Core\Content\Product\Events\ProductSuggestResultEvent;
use Shopware\Core\Content\Product\ProductCollection;
use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Content\Product\SalesChannel\SalesChannelProductEntity;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\System\SalesChannel\Entity\SalesChannelRepositoryInterface;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Shopware\Storefront\Page\Navigation\NavigationPageLoadedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class Storefront implements EventSubscriberInterface
{
private const LIMIT_VARIANTS_ONE_VARIATION = 30;
private const LIMIT_VARIANTS_MORE_VARIATIONS = 20;
/** @var ContainerInterface $container */
private $container;
/** @var SalesChannelRepositoryInterface $salesChannelProductRepository */
private $salesChannelProductRepository;
/** @var SystemConfigService $systemConfigService */
private $systemConfigService;
public function __construct(
ContainerInterface $container,
SalesChannelRepositoryInterface $salesChannelProductRepository,
SystemConfigService $systemConfigService
)
{
$this->container = $container;
$this->salesChannelProductRepository = $salesChannelProductRepository;
$this->systemConfigService = $systemConfigService;
}
public static function getSubscribedEvents(): array
{
return [
ProductListingResultEvent::class => 'enrichVariantProductsForResult',
ProductSearchResultEvent::class => 'enrichVariantProductsForResult',
ProductSuggestResultEvent::class => 'enrichVariantProductsForResult',
ProductCrossSellingsLoadedEvent::class => 'enrichVariantProductsForCrossSelling',
ProductCrossSellingIdsCriteriaEvent::class => 'enrichCrossSellingCriteria',
ProductCrossSellingStreamCriteriaEvent::class => 'enrichCrossSellingCriteria',
NavigationPageLoadedEvent::class => 'enrichCmsSlotProducts',
];
}
/**
* @param ProductCrossSellingCriteriaEvent $event
* @return void
*/
public function enrichCrossSellingCriteria(ProductCrossSellingCriteriaEvent $event) {
$criteria = $event->getCriteria();
$criteria->addAssociation('options');
$criteria->addAssociation('options.group');
}
public function enrichCmsSlotProducts(NavigationPageLoadedEvent $event)
{
$products = [];
// Handle product-box entities
/** @var CmsSlotEntity[] $cmsSlotEntities */
$cmsSlotEntities = $event->getPage()->getCmsPage()->getElementsOfType('product-box');
foreach ($cmsSlotEntities as $cmsSlotEntity) {
$data = $cmsSlotEntity->getData();
if ($data instanceof ProductBoxStruct &&
isset($data->getProduct()->getCustomFields()[Enum::SHOW_IN_CMS_SLOT]) &&
$data->getProduct()->getCustomFields()[Enum::SHOW_IN_CMS_SLOT] === true
) {
$products[] = $data->getProduct();
}
}
// Handle product-slider entities
$cmsSlotEntities = $event->getPage()->getCmsPage()->getElementsOfType('product-slider');
foreach ($cmsSlotEntities as $cmsSlotEntity) {
$data = $cmsSlotEntity->getData();
if ($data instanceof ProductSliderStruct) {
foreach ($data->getProducts() as $product) {
if (isset($product->getCustomFields()[Enum::SHOW_IN_CMS_SLOT]) &&
$product->getCustomFields()[Enum::SHOW_IN_CMS_SLOT] === true
) {
$products[] = $product;
}
}
}
}
$this->enrichVariantProducts($products, $event->getSalesChannelContext());
}
/**
* @param ProductListingResultEvent|ProductSearchResultEvent|ProductSuggestResultEvent $event
* @return void
*/
public function enrichVariantProductsForResult($event): void
{
$routeParams = $event->getRequest()->attributes->get('_route_params', []);
if (isset($routeParams['navigationId'])) {
$navigationId = $routeParams['navigationId'];
$excludedCategoryIds = $this->systemConfigService->get('MkxBetterVariants.config.excludedCategoryIds');
if (is_array($excludedCategoryIds) && in_array($navigationId, $excludedCategoryIds, true)) {
return;
}
}
$useInSearch = $this->systemConfigService->get('MkxBetterVariants.config.useInSearch');
if (!$useInSearch && ($event instanceof ProductSearchResultEvent)) {
return;
}
$products = $event->getResult()->getElements();
$this->enrichVariantProducts($products, $event->getSalesChannelContext());
}
/**
* @param ProductCrossSellingsLoadedEvent $event
* @return void
*/
public function enrichVariantProductsForCrossSelling(ProductCrossSellingsLoadedEvent $event): void
{
$crossSellings = $event->getCrossSellings();
$products = [];
foreach ($crossSellings as $crossSelling) {
$products = array_merge($products, $crossSelling->getProducts()->filter(function ($product) {
return
isset($product->getCustomFields()[Enum::SHOW_IN_CROSS_SELLING])
&& $product->getCustomFields()[Enum::SHOW_IN_CROSS_SELLING] === true;
})->getElements());
}
$this->enrichVariantProducts($products, $event->getSalesChannelContext());
}
/**
* @param ProductEntity[] $products
* @param SalesChannelContext $context
*/
private function enrichVariantProducts(array $products, SalesChannelContext $context): void
{
$parentIds = [];
$parentIdsForChildrenQuery = [];
foreach ($products as $product) {
if (
$product->getParentId() !== null
&& $this->canUseParentData($product)
) {
$parentIds[$product->getId()] = $product->getParentId();
if (!$this->canHideVariants($product)) {
$parentIdsForChildrenQuery[] = $product->getParentId();
}
}
}
if (count($parentIds) == 0) {
return;
}
// Add parent data to the products
$criteria = new Criteria($parentIds);
$productParents = $this->salesChannelProductRepository->search($criteria, $context);
/** @var SalesChannelProductEntity $product */
foreach ($products as $product) {
$parentId = $product->getParentId();
if (
$parentId !== null
&& $product->getMainVariantId() !== null
&& $this->canUseParentData($product)
) {
$parent = $productParents->getEntities()->get($parentId);
if ($parent instanceof SalesChannelProductEntity) {
$product->mkxBVParent = $parent;
$product->mkxBVVariants = null; // If it is not changed later on, the variants will not be shown
}
}
}
// This is kept separate because the parent objects are significantly larger in this case
foreach ($parentIdsForChildrenQuery as $id) {
// Split the queries into 1-id batches to prevent too high memory usage with multiple variant-rich products
$criteria = new Criteria([$id]);
$criteria
->addAssociation('children')
->addAssociation('children.options')
->addAssociation('children.options.group')
;
$parentResult = $this->salesChannelProductRepository->search($criteria, $context);
/** @var SalesChannelProductEntity $product */
foreach ($products as $product) {
$parentId = $product->getParentId();
if (
$parentId !== null
&& $parentId === $id
&& $product->getMainVariantId() !== null
&& $this->canUseParentData($product)
&& !$this->canHideVariants($product)
) {
$parent = $parentResult->first();
if ($parent instanceof SalesChannelProductEntity) {
if ($this->canShowGroups($product)) {
$product->mkxBVGroups = $this->getGroupsForProduct($parent, $this->canShowGroupNumbers($product));
} else {
$variants = $this->getVariantsForProduct($parent);
// Check how many variations in an example product
$firstItem = $variants[array_key_first($variants)];
$numberOfVariations = count($firstItem['variations']);
if ($numberOfVariations === 1) {
$limit = self::LIMIT_VARIANTS_ONE_VARIATION;
} else {
$limit = self::LIMIT_VARIANTS_MORE_VARIATIONS;
}
$product->mkxBVVariants = array_slice($variants, 0, $limit);
}
}
}
}
}
}
private function getGroupsForProduct(ProductEntity $parent, bool $canShowNumbers): array
{
$children = $parent->getChildren();
$groupOptions = [];
if ($children instanceof ProductCollection) {
/** @var ProductEntity $child */
foreach ($children as $child) {
if (!$child->getActive()) {
continue;
}
$variation = $child->getVariation();
foreach ($variation as $singleOptionPair) {
$group = $singleOptionPair['group'];
$option = $singleOptionPair['option'];
$groupOptions[$group][] = $option;
}
}
}
$groupInfo = [];
$groupOptions = array_map('array_unique', $groupOptions);
foreach ($groupOptions as $group => $options) {
$newItem = [
'group' => $group,
];
if ($canShowNumbers) {
$newItem['count'] = count($options);
}
$groupInfo[] = $newItem;
}
if ($canShowNumbers) {
usort($groupInfo, function ($item1, $item2) {
return $item2['count'] <=> $item1['count'];
});
}
return $groupInfo;
}
private function getVariantsForProduct(ProductEntity $parent): array
{
$children = $parent->getChildren();
$variantArray = [];
if ($children instanceof ProductCollection) {
/** @var ProductEntity $child */
foreach ($children as $child) {
if (!$this->canShowProduct($child)) {
continue;
}
$variation = $child->getVariation();
$variantName = implode(' ', array_column($variation, 'option'));
$variantArray[$variantName] = [
'productId' => $child->getId(),
'variations' => $variation,
'position' => count($variation) === 1 ? $child->getOptions()->first()->getPosition() : 0,
];
}
ksort($variantArray, SORT_NATURAL);
uasort($variantArray, function ($item1, $item2) {
return $item1['position'] <=> $item2['position'];
});
}
return $variantArray;
}
private function canShowProduct(ProductEntity $product) {
if (!$product->getActive()) {
return false;
}
if (
$this->container->get(SystemConfigService::class)->get('core.listing.hideCloseoutProductsWhenOutOfStock') === true
&& $product->getIsCloseout()
&& $product->getStock() === 0
) {
return false;
}
return true;
}
private function canUseParentData(ProductEntity $product): bool
{
return $this->isCustomFieldTrue($product, Enum::USE_PARENT_DATA);
}
private function canHideVariants(ProductEntity $product): bool
{
return $this->isCustomFieldTrue($product, Enum::HIDE_VARIANTS);
}
private function canShowGroups(ProductEntity $product): bool
{
return $this->isCustomFieldTrue($product, Enum::SHOW_GROUPS);
}
private function canShowGroupNumbers(ProductEntity $product): bool
{
return $this->isCustomFieldTrue($product, Enum::SHOW_GROUP_NUMBERS);
}
private function isCustomFieldTrue(ProductEntity $product, string $fieldName): bool
{
$customFields = $product->getCustomFields();
return $customFields[$fieldName] ?? false;
}
}