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

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\GenericPageLoader;
  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 GenericPageLoader
  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.         GenericPageLoader                 $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)
  183.     {
  184.         $criteria = new Criteria();
  185.         $criteria->addAssociation('country');
  186.         $criteria->addGroupField(new FieldGrouping('countryId'));
  187.         $result    $this->storeRepository->search($criteria$context->getContext());
  188.         $countries = [
  189.             [
  190.                 'id'       => 'all',
  191.                 'label'    => $this->translator->trans('neti-next-store-locator.index.search.allCountriesLabel'),
  192.                 '_label'   => $this->translator->trans('neti-next-store-locator.index.search.allCountriesLabel'),
  193.                 'isoCode'  => 'ALL',
  194.                 'default'  => false,
  195.                 'position' => -1,
  196.             ],
  197.         ];
  198.         $workingIndex         1;
  199.         $defaultByConfigIndex null;
  200.         $umlautInput  = [ 'ä''Ä''ö''Ö''ü''Ãœ' ];
  201.         $umlautOutput = [ 'ae''Ae''oe''Oe''ue''Ue' ];
  202.         /** @var StoreEntity $entity */
  203.         foreach ($result as $entity) {
  204.             $country $entity->getCountry();
  205.             if (!($country instanceof CountryEntity)) {
  206.                 continue;
  207.             }
  208.             if (null === $defaultByConfigIndex && $country->getId() === ($config['preselectedCountryId'] ?? null)) {
  209.                 $defaultByConfigIndex $workingIndex;
  210.             }
  211.             $countries[$workingIndex++] = [
  212.                 'id'       => $country->getId(),
  213.                 'label'    => $country->getTranslated()['name'],
  214.                 '_label'   => str_replace($umlautInput$umlautOutput$country->getTranslated()['name']),
  215.                 'isoCode'  => $country->getIso(),
  216.                 'default'  => false,
  217.                 'position' => $country->getPosition(),
  218.             ];
  219.         }
  220.         $defaultIndex $defaultByConfigIndex ?? 0;
  221.         $countries[$defaultIndex]['default'] = true;
  222.         $sortBy $config['countrySortBy'] ?? PluginConfigStruct::COUNTRY_SORT_BY_NAME_ASC;
  223.         usort($countries, function ($a$b) use ($sortBy) {
  224.             if ($a['id'] === 'all') {
  225.                 return -1;
  226.             }
  227.             switch ($sortBy) {
  228.                 case PluginConfigStruct::COUNTRY_SORT_BY_NAME_ASC:
  229.                     return $a['_label'] > $b['_label'];
  230.                 case PluginConfigStruct::COUNTRY_SORT_BY_NAME_DESC:
  231.                     return $a['_label'] < $b['_label'];
  232.                 case PluginConfigStruct::COUNTRY_SORT_BY_POSITION_ASC;
  233.                     return $a['position'] > $b['position'];
  234.                 case PluginConfigStruct::COUNTRY_SORT_BY_POSITION_DESC:
  235.                     return $a['position'] < $b['position'];
  236.             }
  237.         });
  238.         return $countries;
  239.     }
  240.     /**
  241.      * Returns a list of stores.
  242.      *
  243.      * @param SalesChannelContext $context
  244.      *
  245.      * @return \Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult
  246.      * @throws \Shopware\Core\Framework\DataAbstractionLayer\Exception\InconsistentCriteriaIdsException
  247.      */
  248.     public function getStores(Request $requestSalesChannelContext $context)
  249.     {
  250.         $criteria = new Criteria();
  251.         $criteria->addAssociation('country');
  252.         $criteria->addAssociation('countryState');
  253.         $criteria->addAssociation('pictureMedia');
  254.         $criteria->addAssociation('iconMedia');
  255.         $criteria->addAssociation('translation');
  256.         $criteria->addAssociation('tags');
  257.         $criteria->addFilter(new EqualsFilter('active'true));
  258.         $criteria->addFilter(new EqualsFilter('salesChannels.id'$context->getSalesChannelId()));
  259.         $criteria->addFilter(
  260.             new NotFilter(
  261.                 NotFilter::CONNECTION_OR,
  262.                 [
  263.                     new EqualsFilter('latitude'null),
  264.                     new EqualsFilter('longitude'null),
  265.                 ]
  266.             )
  267.         );
  268.         if ($request->query->has('radius')) {
  269.             $radius    $request->query->get('radius');
  270.             $longitude $request->query->get('lng');
  271.             $latitude  $request->query->get('lat');
  272.             $radiant   = ('km' === $this->pluginConfig->getDistanceUnit()) ? 6371 3959;
  273.             $limit     $this->pluginConfig->getSearchResultLimit();
  274.             $sql '
  275.                 SELECT
  276.                   LOWER(HEX(s.id)),
  277.                   ( :radiant
  278.                     * ACOS(
  279.                       COS(RADIANS(:latitude))
  280.                       * COS(RADIANS(s.latitude))
  281.                       * COS(RADIANS(s.longitude) - RADIANS(:longitude))
  282.                       + SIN(RADIANS(:latitude))
  283.                       * SIN(RADIANS(s.latitude))
  284.                   )) AS distance
  285.                 FROM neti_store_locator s
  286.                 LEFT JOIN neti_store_sales_channel ssc ON ssc.store_id = s.id
  287.                 WHERE s.active = 1
  288.                   AND s.latitude IS NOT NULL
  289.                   AND s.longitude IS NOT NULL
  290.                   AND ssc.sales_channel_id = :salesChannelId
  291.                 HAVING distance < :radius
  292.                 ORDER BY distance ASC
  293.                 LIMIT :limit
  294.             ';
  295.             $sql str_replace(':limit', (string) $limit$sql);
  296.             $ids $this->db->fetchFirstColumn($sql, [
  297.                 'salesChannelId' => Uuid::fromHexToBytes($context->getSalesChannelId()),
  298.                 'radiant'        => $radiant,
  299.                 'longitude'      => $longitude,
  300.                 'latitude'       => $latitude,
  301.                 'radius'         => $radius,
  302.             ]);
  303.             $criteria->addFilter(new EqualsAnyFilter('id'$ids));
  304.         }
  305.         $iterator = new RepositoryIterator($this->storeRepository$context->getContext(), $criteria);
  306.         $result   null;
  307.         if (!$iterator->getTotal()) {
  308.             return new EntitySearchResult(
  309.                 StoreDefinition::ENTITY_NAME,
  310.                 0,
  311.                 new EntityCollection(),
  312.                 null,
  313.                 $criteria,
  314.                 $context->getContext()
  315.             );
  316.         }
  317.         while ($rows $iterator->fetch()) {
  318.             if (null === $result) {
  319.                 $result $rows;
  320.             } else {
  321.                 foreach ($rows as $row) {
  322.                     $result->add($row);
  323.                 }
  324.             }
  325.         }
  326.         $detailMode      $this->systemConfig->get('NetiNextStoreLocator.config.detailPage');
  327.         $selectedStoreId null;
  328.         if (
  329.             null !== $this->contextService
  330.             && $this->isStorePickupEnabled($context->getContext())
  331.         ) {
  332.             /**
  333.              * @psalm-suppress UndefinedClass The class is only available when StorePickup is installed.
  334.              */
  335.             $selectedStoreId $this->contextService->getSelectedStore();
  336.         }
  337.         /** @var StoreEntity $entity */
  338.         foreach ($result as $entity) {
  339.             if ($entity->getId() === $selectedStoreId) {
  340.                 $entity->addExtension('netiStorePickupSelected', new StoreSelectState());
  341.             }
  342.             switch ($detailMode) {
  343.                 case 'enabled':
  344.                     $entity->setDetailPageEnabled(true);
  345.                     break;
  346.                 case 'disabled':
  347.                     $entity->setDetailPageEnabled(false);
  348.                     break;
  349.                 case 'store':
  350.                     // Keep value
  351.                     break;
  352.             }
  353.         }
  354.         return $result;
  355.     }
  356.     private function getRadiusList(): array
  357.     {
  358.         $values       $this->systemConfig->get('NetiNextStoreLocator.config.searchRadiusValues');
  359.         $defaultValue $this->systemConfig->get('NetiNextStoreLocator.config.defaultSearchRadius');
  360.         $values       trim($values);
  361.         if (empty($values)) {
  362.             return [];
  363.         }
  364.         $values array_unique(explode(';'trim($values'; ')));
  365.         return array_map(
  366.             function ($value) use ($defaultValue) {
  367.                 return [
  368.                     'default' => (int)$value === (int)$defaultValue,
  369.                     'value'   => $value,
  370.                 ];
  371.             },
  372.             $values
  373.         );
  374.     }
  375.     public function getConfig(SalesChannelContext $context): array
  376.     {
  377.         $config = (array)$this->systemConfig->get(PluginConfigFactory::CONFIG_DOMAIN$context->getSalesChannel()->getId());
  378.         foreach ($config as $key => $value) {
  379.             unset ($config[$key]);
  380.             switch ($key) {
  381.                 case 'googleMapIcon':
  382.                     $mediaId $value;
  383.                     if (!empty($mediaId)) {
  384.                         /**
  385.                          * @psalm-suppress MixedArgumentTypeCoercion
  386.                          *
  387.                          * This is the correct way to search for a specific ID
  388.                          */
  389.                         $criteria = new Criteria([ $mediaId ]);
  390.                         $result   $this->mediaRepository->search($criteria$context->getContext());
  391.                         if ($result->count() > 0) {
  392.                             $value $result->first()->getUrl();
  393.                         } else {
  394.                             throw new \Exception('The given mediaId does not exist.');
  395.                         }
  396.                     }
  397.                     break;
  398.                 case 'googleMapIconSize':
  399.                     if (!empty($value)) {
  400.                         if (!preg_match('/^([0-9]+)x([0-9]+)$/'$value)) {
  401.                             throw new \Exception('The given icon size "' $value '" is invalid.');
  402.                         }
  403.                         [ $width$height ] = array_map('intval'explode('x'$value));
  404.                         $value compact('width''height');
  405.                     }
  406.                     break;
  407.             }
  408.             $config[$key] = $value;
  409.         }
  410.         $config['_storePickupEnabled']   = $this->isStorePickupEnabled($context->getContext());
  411.         $config['_cookieConsentEnabled'] = version_compare($this->shopwareVersion'6.4.12.0''<')
  412.             ?: $this->systemConfig->get('core.basicInformation.useDefaultCookieConsent');
  413.         return $config;
  414.     }
  415.     private function getContactSubjectOptions(array $config): array
  416.     {
  417.         $options $config['contactSubjectOptions'] ?? '';
  418.         $options trim($options);
  419.         if (empty($options)) {
  420.             return [];
  421.         }
  422.         return explode(PHP_EOL$options);
  423.     }
  424.     private function isStorePickupEnabled(Context $context): bool
  425.     {
  426.         $criteria = new Criteria();
  427.         $criteria->addFilter(new EqualsFilter('name''NetiNextStorePickup'));
  428.         $result $this->pluginRepository->search($criteria$context);
  429.         $plugin $result->first();
  430.         return $plugin instanceof PluginEntity
  431.             && $plugin->getInstalledAt() !== null
  432.             && $plugin->getActive() === true;
  433.     }
  434. }