custom/plugins/NetiNextStoreLocator/src/Storefront/Page/Store/Listing/StoreListingPageLoader.php line 271

Open in your IDE?
  1. <?php
  2. namespace NetInventors\NetiNextStoreLocator\Storefront\Page\Store\Listing;
  3. use Doctrine\DBAL\Connection;
  4. use NetInventors\NetiNextStoreLocator\Components\CmsPageRenderer;
  5. use NetInventors\NetiNextStoreLocator\Components\ContactForm\ContactForm;
  6. use NetInventors\NetiNextStoreLocator\Core\Content\Store\StoreDefinition;
  7. use NetInventors\NetiNextStoreLocator\Core\Content\Store\StoreEntity;
  8. use NetInventors\NetiNextStoreLocator\Service\PluginConfig;
  9. use NetInventors\NetiNextStoreLocator\Service\PluginConfigFactory;
  10. use NetInventors\NetiNextStoreLocator\Service\StoreFilterService;
  11. use NetInventors\NetiNextStoreLocator\Struct\PluginConfigStruct;
  12. use NetInventors\NetiNextStoreLocator\Struct\StoreSelectState;
  13. use NetInventors\NetiNextStorePickup\Service\ContextService;
  14. use Shopware\Core\Content\Seo\SeoUrlPlaceholderHandlerInterface;
  15. use Shopware\Core\Framework\Context;
  16. use Shopware\Core\Framework\DataAbstractionLayer\Dbal\Common\RepositoryIterator;
  17. use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;
  18. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
  19. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  20. use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
  21. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
  22. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  23. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter;
  24. use Shopware\Core\Framework\DataAbstractionLayer\Search\Grouping\FieldGrouping;
  25. use Shopware\Core\Framework\Plugin\PluginEntity;
  26. use Shopware\Core\Framework\Uuid\Uuid;
  27. use Shopware\Core\System\Country\CountryEntity;
  28. use Shopware\Core\System\SalesChannel\SalesChannelContext;
  29. use Shopware\Core\System\SystemConfig\SystemConfigService;
  30. use Shopware\Storefront\Page\GenericPageLoaderInterface;
  31. use Shopware\Storefront\Page\MetaInformation;
  32. use Symfony\Component\HttpFoundation\Request;
  33. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  34. use Symfony\Contracts\Translation\TranslatorInterface;
  35. /**
  36.  * @psalm-suppress UndefinedClass The class is only available when StorePickup is installed.
  37.  */
  38. class StoreListingPageLoader
  39. {
  40.     /**
  41.      * @var GenericPageLoaderInterface
  42.      */
  43.     private $genericLoader;
  44.     /**
  45.      * @var EntityRepositoryInterface
  46.      */
  47.     private $storeRepository;
  48.     /**
  49.      * @var SystemConfigService
  50.      */
  51.     private $systemConfig;
  52.     /**
  53.      * @var ContactForm
  54.      */
  55.     private $contactForm;
  56.     /**
  57.      * @var EntityRepositoryInterface
  58.      */
  59.     private $mediaRepository;
  60.     /**
  61.      * @var CmsPageRenderer
  62.      */
  63.     private $cmsPageRenderer;
  64.     /**
  65.      * @var EventDispatcherInterface
  66.      */
  67.     private $eventDispatcher;
  68.     /**
  69.      * @var TranslatorInterface
  70.      */
  71.     private                           $translator;
  72.     private EntityRepositoryInterface $pluginRepository;
  73.     private StoreFilterService        $storeFilterService;
  74.     /**
  75.      * @var ContextService|null
  76.      */
  77.     private ?ContextService                   $contextService;
  78.     private SeoUrlPlaceholderHandlerInterface $seoUrlReplacer;
  79.     private string                            $shopwareVersion;
  80.     private Connection                        $db;
  81.     private PluginConfigStruct                $pluginConfig;
  82.     /**
  83.      * @psalm-suppress UndefinedClass The class is only available when StorePickup is installed.
  84.      */
  85.     public function __construct(
  86.         GenericPageLoaderInterface        $genericLoader,
  87.         EntityRepositoryInterface         $storeRepository,
  88.         SystemConfigService               $systemConfig,
  89.         ContactForm                       $contactForm,
  90.         EntityRepositoryInterface         $mediaRepository,
  91.         CmsPageRenderer                   $cmsPageRenderer,
  92.         EventDispatcherInterface          $eventDispatcher,
  93.         TranslatorInterface               $translator,
  94.         EntityRepositoryInterface         $pluginRepository,
  95.         StoreFilterService                $storeFilterService,
  96.         ?ContextService                   $contextService,
  97.         SeoUrlPlaceholderHandlerInterface $seoUrlReplacer,
  98.         string                            $shopwareVersion,
  99.         Connection                        $db,
  100.         PluginConfigStruct                $pluginConfig
  101.     ) {
  102.         $this->genericLoader      $genericLoader;
  103.         $this->storeRepository    $storeRepository;
  104.         $this->systemConfig       $systemConfig;
  105.         $this->contactForm        $contactForm;
  106.         $this->mediaRepository    $mediaRepository;
  107.         $this->cmsPageRenderer    $cmsPageRenderer;
  108.         $this->eventDispatcher    $eventDispatcher;
  109.         $this->translator         $translator;
  110.         $this->pluginRepository   $pluginRepository;
  111.         $this->storeFilterService $storeFilterService;
  112.         $this->contextService     $contextService;
  113.         $this->seoUrlReplacer     $seoUrlReplacer;
  114.         $this->shopwareVersion    $shopwareVersion;
  115.         $this->db                 $db;
  116.         $this->pluginConfig       $pluginConfig;
  117.     }
  118.     public function load(Request $requestSalesChannelContext $context): StoreListingPage
  119.     {
  120.         $page $this->genericLoader->load($request$context);
  121.         /** @var StoreListingPage $page */
  122.         $page StoreListingPage::createFrom($page);
  123.         $meta $page->getMetaInformation();
  124.         /** @var array<string, string> $config */
  125.         $config $this->getConfig($context);
  126.         $page->setConfig($config);
  127.         if ($meta instanceof MetaInformation) {
  128.             $meta->setMetaTitle(
  129.                 $this->translator->trans('neti-next-store-locator.index.title')
  130.             );
  131.             $meta->setMetaDescription(
  132.                 $this->translator->trans('neti-next-store-locator.index.description')
  133.             );
  134.             $seoUrl $config['seoUrl'];
  135.             if ('' !== $seoUrl) {
  136.                 $storefrontUrl = (string)$request->attributes->get('sw-storefront-url');
  137.                 $meta->assign([ 'canonical' => $storefrontUrl '/' $config['seoUrl'] ]);
  138.             } else {
  139.                 $meta->assign([ 'canonical' => $this->seoUrlReplacer->generate('frontend.store_locator.index') ]);
  140.             }
  141.         }
  142.         $countries $this->getCountries($context$config);
  143.         $page->setCountries($countries);
  144.         $filters $this->storeFilterService->loadFiltersForStorefront($context);
  145.         $page->setFilters($filters);
  146.         $radiusList $this->getRadiusList();
  147.         $page->setRadiusList($radiusList);
  148.         $contactFormFields $this->contactForm->getFields($context);
  149.         $page->setContactFormFields($contactFormFields);
  150.         $contactSubjectOptions $this->getContactSubjectOptions($config);
  151.         $page->setContactSubjectOptions($contactSubjectOptions);
  152.         $page->setOrderTypes(
  153.             [
  154.                 'distance',
  155.                 'country',
  156.                 'name',
  157.                 'random',
  158.             ]
  159.         );
  160.         if (isset($config['topCmsPage']) && '' !== $config['topCmsPage']) {
  161.             $page->setTopCmsPageHtml(
  162.                 $this->cmsPageRenderer->buildById($request$context$config['topCmsPage'], compact('page'))
  163.             );
  164.         }
  165.         if (isset($config['bottomCmsPage']) && '' !== $config['bottomCmsPage']) {
  166.             $page->setBottomCmsPageHtml(
  167.                 $this->cmsPageRenderer->buildById($request$context$config['bottomCmsPage'], compact('page'))
  168.             );
  169.         }
  170.         $this->eventDispatcher->dispatch(new StoreListingPageLoadedEvent($page$context$request));
  171.         return $page;
  172.     }
  173.     /**
  174.      * Returns a list of all used countries
  175.      *
  176.      * @param SalesChannelContext $context
  177.      * @param array               $config
  178.      *
  179.      * @return array
  180.      * @throws \Shopware\Core\Framework\DataAbstractionLayer\Exception\InconsistentCriteriaIdsException
  181.      */
  182.     public function getCountries(SalesChannelContext $context, array $config): array
  183.     {
  184.         $criteria = new Criteria();
  185.         $criteria->addAssociation('country');
  186.         $criteria->addGroupField(new FieldGrouping('countryId'));
  187.         $criteria->addFilter(new EqualsFilter('salesChannels.id'$context->getSalesChannelId()));
  188.         $result    $this->storeRepository->search($criteria$context->getContext());
  189.         $countries = [
  190.             [
  191.                 'id'       => 'all',
  192.                 'label'    => $this->translator->trans('neti-next-store-locator.index.search.allCountriesLabel'),
  193.                 '_label'   => $this->translator->trans('neti-next-store-locator.index.search.allCountriesLabel'),
  194.                 'isoCode'  => 'ALL',
  195.                 'default'  => false,
  196.                 'position' => -1,
  197.             ],
  198.         ];
  199.         $workingIndex         1;
  200.         $defaultByConfigIndex null;
  201.         $umlautInput  = [ 'ä''Ä''ö''Ö''ü''Ãœ' ];
  202.         $umlautOutput = [ 'ae''Ae''oe''Oe''ue''Ue' ];
  203.         /** @var StoreEntity $entity */
  204.         foreach ($result as $entity) {
  205.             $country $entity->getCountry();
  206.             if (!($country instanceof CountryEntity)) {
  207.                 continue;
  208.             }
  209.             if (null === $defaultByConfigIndex && $country->getId() === ($config['preselectedCountryId'] ?? null)) {
  210.                 $defaultByConfigIndex $workingIndex;
  211.             }
  212.             $countries[$workingIndex++] = [
  213.                 'id'       => $country->getId(),
  214.                 'label'    => $country->getTranslated()['name'],
  215.                 '_label'   => str_replace($umlautInput$umlautOutput$country->getTranslated()['name']),
  216.                 'isoCode'  => $country->getIso(),
  217.                 'default'  => false,
  218.                 'position' => $country->getPosition(),
  219.             ];
  220.         }
  221.         $defaultIndex $defaultByConfigIndex ?? 0;
  222.         $countries[$defaultIndex]['default'] = true;
  223.         $sortBy $config['countrySortBy'] ?? PluginConfigStruct::COUNTRY_SORT_BY_NAME_ASC;
  224.         usort($countries, function ($a$b) use ($sortBy) {
  225.             if ($a['id'] === 'all') {
  226.                 return -1;
  227.             }
  228.             switch ($sortBy) {
  229.                 case PluginConfigStruct::COUNTRY_SORT_BY_NAME_ASC:
  230.                     return $a['_label'] > $b['_label'];
  231.                 case PluginConfigStruct::COUNTRY_SORT_BY_NAME_DESC:
  232.                     return $a['_label'] < $b['_label'];
  233.                 case PluginConfigStruct::COUNTRY_SORT_BY_POSITION_ASC;
  234.                     return $a['position'] > $b['position'];
  235.                 case PluginConfigStruct::COUNTRY_SORT_BY_POSITION_DESC:
  236.                     return $a['position'] < $b['position'];
  237.             }
  238.         });
  239.         return $countries;
  240.     }
  241.     /**
  242.      * Returns a list of stores.
  243.      *
  244.      * @param SalesChannelContext $context
  245.      *
  246.      * @return \Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult
  247.      * @throws \Shopware\Core\Framework\DataAbstractionLayer\Exception\InconsistentCriteriaIdsException
  248.      */
  249.     public function getStores(Request $requestSalesChannelContext $context)
  250.     {
  251.         $criteria = new Criteria();
  252.         $criteria->addAssociation('country');
  253.         $criteria->addAssociation('countryState');
  254.         $criteria->addAssociation('pictureMedia');
  255.         $criteria->addAssociation('iconMedia');
  256.         $criteria->addAssociation('translation');
  257.         $criteria->addAssociation('tags');
  258.         $criteria->addFilter(new EqualsFilter('active'true));
  259.         $criteria->addFilter(new EqualsFilter('salesChannels.id'$context->getSalesChannelId()));
  260.         $criteria->addFilter(
  261.             new NotFilter(
  262.                 NotFilter::CONNECTION_OR,
  263.                 [
  264.                     new EqualsFilter('latitude'null),
  265.                     new EqualsFilter('longitude'null),
  266.                 ]
  267.             )
  268.         );
  269.         if ($request->query->has('radius')) {
  270.             $radius    $request->query->get('radius');
  271.             $longitude $request->query->get('lng');
  272.             $latitude  $request->query->get('lat');
  273.             $radiant   = ('km' === $this->pluginConfig->getDistanceUnit()) ? 6371 3959;
  274.             $limit     $this->pluginConfig->getSearchResultLimit();
  275.             $sql '
  276.                 SELECT
  277.                   LOWER(HEX(s.id)),
  278.                   ( :radiant
  279.                     * ACOS(
  280.                       COS(RADIANS(:latitude))
  281.                       * COS(RADIANS(s.latitude))
  282.                       * COS(RADIANS(s.longitude) - RADIANS(:longitude))
  283.                       + SIN(RADIANS(:latitude))
  284.                       * SIN(RADIANS(s.latitude))
  285.                   )) AS distance
  286.                 FROM neti_store_locator s
  287.                 LEFT JOIN neti_store_sales_channel ssc ON ssc.store_id = s.id
  288.                 WHERE s.active = 1
  289.                   AND s.latitude IS NOT NULL
  290.                   AND s.longitude IS NOT NULL
  291.                   AND ssc.sales_channel_id = :salesChannelId
  292.                 HAVING distance < :radius
  293.                 ORDER BY distance ASC
  294.                 LIMIT :limit
  295.             ';
  296.             $sql str_replace(':limit', (string) $limit$sql);
  297.             $ids $this->db->fetchFirstColumn($sql, [
  298.                 'salesChannelId' => Uuid::fromHexToBytes($context->getSalesChannelId()),
  299.                 'radiant'        => $radiant,
  300.                 'longitude'      => $longitude,
  301.                 'latitude'       => $latitude,
  302.                 'radius'         => $radius,
  303.             ]);
  304.             $criteria->addFilter(new EqualsAnyFilter('id'$ids));
  305.         }
  306.         $iterator = new RepositoryIterator($this->storeRepository$context->getContext(), $criteria);
  307.         $result   null;
  308.         if (!$iterator->getTotal()) {
  309.             return new EntitySearchResult(
  310.                 StoreDefinition::ENTITY_NAME,
  311.                 0,
  312.                 new EntityCollection(),
  313.                 null,
  314.                 $criteria,
  315.                 $context->getContext()
  316.             );
  317.         }
  318.         while ($rows $iterator->fetch()) {
  319.             if (null === $result) {
  320.                 $result $rows;
  321.             } else {
  322.                 foreach ($rows as $row) {
  323.                     $result->add($row);
  324.                 }
  325.             }
  326.         }
  327.         $detailMode      $this->systemConfig->get('NetiNextStoreLocator.config.detailPage'$context->getSalesChannelId());
  328.         $selectedStoreId null;
  329.         if (
  330.             null !== $this->contextService
  331.             && $this->isStorePickupEnabled($context->getContext())
  332.         ) {
  333.             /**
  334.              * @psalm-suppress UndefinedClass The class is only available when StorePickup is installed.
  335.              */
  336.             $selectedStoreId $this->contextService->getSelectedStore();
  337.         }
  338.         /** @var StoreEntity $entity */
  339.         foreach ($result as $entity) {
  340.             if ($entity->getId() === $selectedStoreId) {
  341.                 $entity->addExtension('netiStorePickupSelected', new StoreSelectState());
  342.             }
  343.             switch ($detailMode) {
  344.                 case 'enabled':
  345.                     $entity->setDetailPageEnabled(true);
  346.                     break;
  347.                 case 'disabled':
  348.                     $entity->setDetailPageEnabled(false);
  349.                     break;
  350.                 case 'store':
  351.                     // Keep value
  352.                     break;
  353.             }
  354.         }
  355.         return $result;
  356.     }
  357.     private function getRadiusList(): array
  358.     {
  359.         $values       $this->systemConfig->get('NetiNextStoreLocator.config.searchRadiusValues');
  360.         $defaultValue $this->systemConfig->get('NetiNextStoreLocator.config.defaultSearchRadius');
  361.         $values       trim($values);
  362.         if (empty($values)) {
  363.             return [];
  364.         }
  365.         $values array_unique(explode(';'trim($values'; ')));
  366.         return array_map(
  367.             function ($value) use ($defaultValue) {
  368.                 return [
  369.                     'default' => (int)$value === (int)$defaultValue,
  370.                     'value'   => $value,
  371.                 ];
  372.             },
  373.             $values
  374.         );
  375.     }
  376.     public function getConfig(SalesChannelContext $context): array
  377.     {
  378.         $config = (array)$this->systemConfig->get(PluginConfigFactory::CONFIG_DOMAIN$context->getSalesChannel()->getId());
  379.         foreach ($config as $key => $value) {
  380.             unset ($config[$key]);
  381.             switch ($key) {
  382.                 case 'googleMapIcon':
  383.                     $mediaId $value;
  384.                     if (!empty($mediaId)) {
  385.                         /**
  386.                          * @psalm-suppress MixedArgumentTypeCoercion
  387.                          *
  388.                          * This is the correct way to search for a specific ID
  389.                          */
  390.                         $criteria = new Criteria([ $mediaId ]);
  391.                         $result   $this->mediaRepository->search($criteria$context->getContext());
  392.                         if ($result->count() > 0) {
  393.                             $value $result->first()->getUrl();
  394.                         } else {
  395.                             throw new \Exception('The given mediaId does not exist.');
  396.                         }
  397.                     }
  398.                     break;
  399.                 case 'googleMapIconSize':
  400.                     if (!empty($value)) {
  401.                         if (!preg_match('/^([0-9]+)x([0-9]+)$/'$value)) {
  402.                             throw new \Exception('The given icon size "' $value '" is invalid.');
  403.                         }
  404.                         [ $width$height ] = array_map('intval'explode('x'$value));
  405.                         $value compact('width''height');
  406.                     }
  407.                     break;
  408.             }
  409.             $config[$key] = $value;
  410.         }
  411.         $config['_storePickupEnabled']   = $this->isStorePickupEnabled($context->getContext());
  412.         $config['_cookieConsentEnabled'] = version_compare($this->shopwareVersion'6.4.12.0''<')
  413.             ?: $this->systemConfig->get('core.basicInformation.useDefaultCookieConsent');
  414.         return $config;
  415.     }
  416.     private function getContactSubjectOptions(array $config): array
  417.     {
  418.         $options $config['contactSubjectOptions'] ?? '';
  419.         $options trim($options);
  420.         if (empty($options)) {
  421.             return [];
  422.         }
  423.         return explode(PHP_EOL$options);
  424.     }
  425.     private function isStorePickupEnabled(Context $context): bool
  426.     {
  427.         $criteria = new Criteria();
  428.         $criteria->addFilter(new EqualsFilter('name''NetiNextStorePickup'));
  429.         $result $this->pluginRepository->search($criteria$context);
  430.         $plugin $result->first();
  431.         return $plugin instanceof PluginEntity
  432.             && $plugin->getInstalledAt() !== null
  433.             && $plugin->getActive() === true;
  434.     }
  435. }