<?php
namespace NetInventors\NetiNextStoreLocator\Storefront\Page\Store\Listing;
use Doctrine\DBAL\Connection;
use NetInventors\NetiNextStoreLocator\Components\CmsPageRenderer;
use NetInventors\NetiNextStoreLocator\Components\ContactForm\ContactForm;
use NetInventors\NetiNextStoreLocator\Core\Content\Store\StoreDefinition;
use NetInventors\NetiNextStoreLocator\Core\Content\Store\StoreEntity;
use NetInventors\NetiNextStoreLocator\Service\PluginConfig;
use NetInventors\NetiNextStoreLocator\Service\PluginConfigFactory;
use NetInventors\NetiNextStoreLocator\Service\StoreFilterService;
use NetInventors\NetiNextStoreLocator\Struct\PluginConfigStruct;
use NetInventors\NetiNextStoreLocator\Struct\StoreSelectState;
use NetInventors\NetiNextStorePickup\Service\ContextService;
use Shopware\Core\Content\Seo\SeoUrlPlaceholderHandlerInterface;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\Common\RepositoryIterator;
use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Grouping\FieldGrouping;
use Shopware\Core\Framework\Plugin\PluginEntity;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\System\Country\CountryEntity;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Shopware\Storefront\Page\GenericPageLoader;
use Shopware\Storefront\Page\MetaInformation;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @psalm-suppress UndefinedClass The class is only available when StorePickup is installed.
*/
class StoreListingPageLoader
{
/**
* @var GenericPageLoader
*/
private $genericLoader;
/**
* @var EntityRepositoryInterface
*/
private $storeRepository;
/**
* @var SystemConfigService
*/
private $systemConfig;
/**
* @var ContactForm
*/
private $contactForm;
/**
* @var EntityRepositoryInterface
*/
private $mediaRepository;
/**
* @var CmsPageRenderer
*/
private $cmsPageRenderer;
/**
* @var EventDispatcherInterface
*/
private $eventDispatcher;
/**
* @var TranslatorInterface
*/
private $translator;
private EntityRepositoryInterface $pluginRepository;
private StoreFilterService $storeFilterService;
/**
* @var ContextService|null
*/
private ?ContextService $contextService;
private SeoUrlPlaceholderHandlerInterface $seoUrlReplacer;
private string $shopwareVersion;
private Connection $db;
private PluginConfigStruct $pluginConfig;
/**
* @psalm-suppress UndefinedClass The class is only available when StorePickup is installed.
*/
public function __construct(
GenericPageLoader $genericLoader,
EntityRepositoryInterface $storeRepository,
SystemConfigService $systemConfig,
ContactForm $contactForm,
EntityRepositoryInterface $mediaRepository,
CmsPageRenderer $cmsPageRenderer,
EventDispatcherInterface $eventDispatcher,
TranslatorInterface $translator,
EntityRepositoryInterface $pluginRepository,
StoreFilterService $storeFilterService,
?ContextService $contextService,
SeoUrlPlaceholderHandlerInterface $seoUrlReplacer,
string $shopwareVersion,
Connection $db,
PluginConfigStruct $pluginConfig
) {
$this->genericLoader = $genericLoader;
$this->storeRepository = $storeRepository;
$this->systemConfig = $systemConfig;
$this->contactForm = $contactForm;
$this->mediaRepository = $mediaRepository;
$this->cmsPageRenderer = $cmsPageRenderer;
$this->eventDispatcher = $eventDispatcher;
$this->translator = $translator;
$this->pluginRepository = $pluginRepository;
$this->storeFilterService = $storeFilterService;
$this->contextService = $contextService;
$this->seoUrlReplacer = $seoUrlReplacer;
$this->shopwareVersion = $shopwareVersion;
$this->db = $db;
$this->pluginConfig = $pluginConfig;
}
public function load(Request $request, SalesChannelContext $context): StoreListingPage
{
$page = $this->genericLoader->load($request, $context);
/** @var StoreListingPage $page */
$page = StoreListingPage::createFrom($page);
$meta = $page->getMetaInformation();
/** @var array<string, string> $config */
$config = $this->getConfig($context);
$page->setConfig($config);
if ($meta instanceof MetaInformation) {
$meta->setMetaTitle(
$this->translator->trans('neti-next-store-locator.index.title')
);
$meta->setMetaDescription(
$this->translator->trans('neti-next-store-locator.index.description')
);
$seoUrl = $config['seoUrl'];
if ('' !== $seoUrl) {
$storefrontUrl = (string)$request->attributes->get('sw-storefront-url');
$meta->assign([ 'canonical' => $storefrontUrl . '/' . $config['seoUrl'] ]);
} else {
$meta->assign([ 'canonical' => $this->seoUrlReplacer->generate('frontend.store_locator.index') ]);
}
}
$countries = $this->getCountries($context, $config);
$page->setCountries($countries);
$filters = $this->storeFilterService->loadFiltersForStorefront($context);
$page->setFilters($filters);
$radiusList = $this->getRadiusList();
$page->setRadiusList($radiusList);
$contactFormFields = $this->contactForm->getFields($context);
$page->setContactFormFields($contactFormFields);
$contactSubjectOptions = $this->getContactSubjectOptions($config);
$page->setContactSubjectOptions($contactSubjectOptions);
$page->setOrderTypes(
[
'distance',
'country',
'name',
'random',
]
);
if (isset($config['topCmsPage']) && '' !== $config['topCmsPage']) {
$page->setTopCmsPageHtml(
$this->cmsPageRenderer->buildById($request, $context, $config['topCmsPage'], compact('page'))
);
}
if (isset($config['bottomCmsPage']) && '' !== $config['bottomCmsPage']) {
$page->setBottomCmsPageHtml(
$this->cmsPageRenderer->buildById($request, $context, $config['bottomCmsPage'], compact('page'))
);
}
$this->eventDispatcher->dispatch(new StoreListingPageLoadedEvent($page, $context, $request));
return $page;
}
/**
* Returns a list of all used countries
*
* @param SalesChannelContext $context
* @param array $config
*
* @return array
* @throws \Shopware\Core\Framework\DataAbstractionLayer\Exception\InconsistentCriteriaIdsException
*/
public function getCountries(SalesChannelContext $context, array $config)
{
$criteria = new Criteria();
$criteria->addAssociation('country');
$criteria->addGroupField(new FieldGrouping('countryId'));
$result = $this->storeRepository->search($criteria, $context->getContext());
$countries = [
[
'id' => 'all',
'label' => $this->translator->trans('neti-next-store-locator.index.search.allCountriesLabel'),
'_label' => $this->translator->trans('neti-next-store-locator.index.search.allCountriesLabel'),
'isoCode' => 'ALL',
'default' => false,
'position' => -1,
],
];
$workingIndex = 1;
$defaultByConfigIndex = null;
$umlautInput = [ 'ä', 'Ä', 'ö', 'Ö', 'ü', 'Ü' ];
$umlautOutput = [ 'ae', 'Ae', 'oe', 'Oe', 'ue', 'Ue' ];
/** @var StoreEntity $entity */
foreach ($result as $entity) {
$country = $entity->getCountry();
if (!($country instanceof CountryEntity)) {
continue;
}
if (null === $defaultByConfigIndex && $country->getId() === ($config['preselectedCountryId'] ?? null)) {
$defaultByConfigIndex = $workingIndex;
}
$countries[$workingIndex++] = [
'id' => $country->getId(),
'label' => $country->getTranslated()['name'],
'_label' => str_replace($umlautInput, $umlautOutput, $country->getTranslated()['name']),
'isoCode' => $country->getIso(),
'default' => false,
'position' => $country->getPosition(),
];
}
$defaultIndex = $defaultByConfigIndex ?? 0;
$countries[$defaultIndex]['default'] = true;
$sortBy = $config['countrySortBy'] ?? PluginConfigStruct::COUNTRY_SORT_BY_NAME_ASC;
usort($countries, function ($a, $b) use ($sortBy) {
if ($a['id'] === 'all') {
return -1;
}
switch ($sortBy) {
case PluginConfigStruct::COUNTRY_SORT_BY_NAME_ASC:
return $a['_label'] > $b['_label'];
case PluginConfigStruct::COUNTRY_SORT_BY_NAME_DESC:
return $a['_label'] < $b['_label'];
case PluginConfigStruct::COUNTRY_SORT_BY_POSITION_ASC;
return $a['position'] > $b['position'];
case PluginConfigStruct::COUNTRY_SORT_BY_POSITION_DESC:
return $a['position'] < $b['position'];
}
});
return $countries;
}
/**
* Returns a list of stores.
*
* @param SalesChannelContext $context
*
* @return \Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult
* @throws \Shopware\Core\Framework\DataAbstractionLayer\Exception\InconsistentCriteriaIdsException
*/
public function getStores(Request $request, SalesChannelContext $context)
{
$criteria = new Criteria();
$criteria->addAssociation('country');
$criteria->addAssociation('countryState');
$criteria->addAssociation('pictureMedia');
$criteria->addAssociation('iconMedia');
$criteria->addAssociation('translation');
$criteria->addAssociation('tags');
$criteria->addFilter(new EqualsFilter('active', true));
$criteria->addFilter(new EqualsFilter('salesChannels.id', $context->getSalesChannelId()));
$criteria->addFilter(
new NotFilter(
NotFilter::CONNECTION_OR,
[
new EqualsFilter('latitude', null),
new EqualsFilter('longitude', null),
]
)
);
if ($request->query->has('radius')) {
$radius = $request->query->get('radius');
$longitude = $request->query->get('lng');
$latitude = $request->query->get('lat');
$radiant = ('km' === $this->pluginConfig->getDistanceUnit()) ? 6371 : 3959;
$limit = $this->pluginConfig->getSearchResultLimit();
$sql = '
SELECT
LOWER(HEX(s.id)),
( :radiant
* ACOS(
COS(RADIANS(:latitude))
* COS(RADIANS(s.latitude))
* COS(RADIANS(s.longitude) - RADIANS(:longitude))
+ SIN(RADIANS(:latitude))
* SIN(RADIANS(s.latitude))
)) AS distance
FROM neti_store_locator s
LEFT JOIN neti_store_sales_channel ssc ON ssc.store_id = s.id
WHERE s.active = 1
AND s.latitude IS NOT NULL
AND s.longitude IS NOT NULL
AND ssc.sales_channel_id = :salesChannelId
HAVING distance < :radius
ORDER BY distance ASC
LIMIT :limit
';
$sql = str_replace(':limit', (string) $limit, $sql);
$ids = $this->db->fetchFirstColumn($sql, [
'salesChannelId' => Uuid::fromHexToBytes($context->getSalesChannelId()),
'radiant' => $radiant,
'longitude' => $longitude,
'latitude' => $latitude,
'radius' => $radius,
]);
$criteria->addFilter(new EqualsAnyFilter('id', $ids));
}
$iterator = new RepositoryIterator($this->storeRepository, $context->getContext(), $criteria);
$result = null;
if (!$iterator->getTotal()) {
return new EntitySearchResult(
StoreDefinition::ENTITY_NAME,
0,
new EntityCollection(),
null,
$criteria,
$context->getContext()
);
}
while ($rows = $iterator->fetch()) {
if (null === $result) {
$result = $rows;
} else {
foreach ($rows as $row) {
$result->add($row);
}
}
}
$detailMode = $this->systemConfig->get('NetiNextStoreLocator.config.detailPage');
$selectedStoreId = null;
if (
null !== $this->contextService
&& $this->isStorePickupEnabled($context->getContext())
) {
/**
* @psalm-suppress UndefinedClass The class is only available when StorePickup is installed.
*/
$selectedStoreId = $this->contextService->getSelectedStore();
}
/** @var StoreEntity $entity */
foreach ($result as $entity) {
if ($entity->getId() === $selectedStoreId) {
$entity->addExtension('netiStorePickupSelected', new StoreSelectState());
}
switch ($detailMode) {
case 'enabled':
$entity->setDetailPageEnabled(true);
break;
case 'disabled':
$entity->setDetailPageEnabled(false);
break;
case 'store':
// Keep value
break;
}
}
return $result;
}
private function getRadiusList(): array
{
$values = $this->systemConfig->get('NetiNextStoreLocator.config.searchRadiusValues');
$defaultValue = $this->systemConfig->get('NetiNextStoreLocator.config.defaultSearchRadius');
$values = trim($values);
if (empty($values)) {
return [];
}
$values = array_unique(explode(';', trim($values, '; ')));
return array_map(
function ($value) use ($defaultValue) {
return [
'default' => (int)$value === (int)$defaultValue,
'value' => $value,
];
},
$values
);
}
public function getConfig(SalesChannelContext $context): array
{
$config = (array)$this->systemConfig->get(PluginConfigFactory::CONFIG_DOMAIN, $context->getSalesChannel()->getId());
foreach ($config as $key => $value) {
unset ($config[$key]);
switch ($key) {
case 'googleMapIcon':
$mediaId = $value;
if (!empty($mediaId)) {
/**
* @psalm-suppress MixedArgumentTypeCoercion
*
* This is the correct way to search for a specific ID
*/
$criteria = new Criteria([ $mediaId ]);
$result = $this->mediaRepository->search($criteria, $context->getContext());
if ($result->count() > 0) {
$value = $result->first()->getUrl();
} else {
throw new \Exception('The given mediaId does not exist.');
}
}
break;
case 'googleMapIconSize':
if (!empty($value)) {
if (!preg_match('/^([0-9]+)x([0-9]+)$/', $value)) {
throw new \Exception('The given icon size "' . $value . '" is invalid.');
}
[ $width, $height ] = array_map('intval', explode('x', $value));
$value = compact('width', 'height');
}
break;
}
$config[$key] = $value;
}
$config['_storePickupEnabled'] = $this->isStorePickupEnabled($context->getContext());
$config['_cookieConsentEnabled'] = version_compare($this->shopwareVersion, '6.4.12.0', '<')
?: $this->systemConfig->get('core.basicInformation.useDefaultCookieConsent');
return $config;
}
private function getContactSubjectOptions(array $config): array
{
$options = $config['contactSubjectOptions'] ?? '';
$options = trim($options);
if (empty($options)) {
return [];
}
return explode(PHP_EOL, $options);
}
private function isStorePickupEnabled(Context $context): bool
{
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('name', 'NetiNextStorePickup'));
$result = $this->pluginRepository->search($criteria, $context);
$plugin = $result->first();
return $plugin instanceof PluginEntity
&& $plugin->getInstalledAt() !== null
&& $plugin->getActive() === true;
}
}