* @copyright Since 2007 PrestaShop SA and Contributors * @license https://opensource.org/licenses/AFL-3.0 Academic Free License 3.0 (AFL-3.0) */ namespace PrestaShop\Module\FacetedSearch\Product; use Configuration; use PrestaShop\Module\FacetedSearch\Filters; use PrestaShop\Module\FacetedSearch\URLSerializer; use PrestaShop\PrestaShop\Core\Product\Search\Facet; use PrestaShop\PrestaShop\Core\Product\Search\FacetCollection; use PrestaShop\PrestaShop\Core\Product\Search\FacetsRendererInterface; use PrestaShop\PrestaShop\Core\Product\Search\ProductSearchContext; use PrestaShop\PrestaShop\Core\Product\Search\ProductSearchProviderInterface; use PrestaShop\PrestaShop\Core\Product\Search\ProductSearchQuery; use PrestaShop\PrestaShop\Core\Product\Search\ProductSearchResult; use PrestaShop\PrestaShop\Core\Product\Search\SortOrder; use Ps_Facetedsearch; use Tools; class SearchProvider implements FacetsRendererInterface, ProductSearchProviderInterface { /** * @var Ps_Facetedsearch */ private $module; /** * @var Filters\Converter */ private $filtersConverter; /** * @var Filters\DataAccessor */ private $dataAccessor; /** * @var URLSerializer */ private $urlSerializer; /** * @var SearchFactory */ private $searchFactory; /** * @var Filters\Provider */ private $provider; public function __construct( Ps_Facetedsearch $module, Filters\Converter $converter, URLSerializer $serializer, Filters\DataAccessor $dataAccessor, SearchFactory $searchFactory = null, Filters\Provider $provider ) { $this->module = $module; $this->filtersConverter = $converter; $this->urlSerializer = $serializer; $this->dataAccessor = $dataAccessor; $this->searchFactory = $searchFactory === null ? new SearchFactory() : $searchFactory; $this->provider = $provider; } /** * @param ProductSearchQuery $query * * @return array */ private function getAvailableSortOrders($query) { $sortSalesDesc = new SortOrder('product', 'sales', 'desc'); $sortPosAsc = new SortOrder('product', 'position', 'asc'); $sortNameAsc = new SortOrder('product', 'name', 'asc'); $sortNameDesc = new SortOrder('product', 'name', 'desc'); $sortPriceAsc = new SortOrder('product', 'price', 'asc'); $sortPriceDesc = new SortOrder('product', 'price', 'desc'); $sortDateAsc = new SortOrder('product', 'date_add', 'asc'); $sortDateDesc = new SortOrder('product', 'date_add', 'desc'); $sortRefAsc = new SortOrder('product', 'reference', 'asc'); $sortRefDesc = new SortOrder('product', 'reference', 'desc'); $translator = $this->module->getTranslator(); $sortOrders = [ $sortSalesDesc->setLabel( $translator->trans('Sales, highest to lowest', [], 'Shop.Theme.Catalog') ), $sortPosAsc->setLabel( $translator->trans('Relevance', [], 'Shop.Theme.Catalog') ), $sortNameAsc->setLabel( $translator->trans('Name, A to Z', [], 'Shop.Theme.Catalog') ), $sortNameDesc->setLabel( $translator->trans('Name, Z to A', [], 'Shop.Theme.Catalog') ), $sortPriceAsc->setLabel( $translator->trans('Price, low to high', [], 'Shop.Theme.Catalog') ), $sortPriceDesc->setLabel( $translator->trans('Price, high to low', [], 'Shop.Theme.Catalog') ), $sortRefAsc->setLabel( $translator->trans('Reference, A to Z', [], 'Shop.Theme.Catalog') ), $sortRefDesc->setLabel( $translator->trans('Reference, Z to A', [], 'Shop.Theme.Catalog') ), ]; if ($query->getQueryType() == 'new-products') { $sortOrders[] = $sortDateAsc->setLabel( $translator->trans('Date added, oldest to newest', [], 'Shop.Theme.Catalog') ); $sortOrders[] = $sortDateDesc->setLabel( $translator->trans('Date added, newest to oldest', [], 'Shop.Theme.Catalog') ); } return $sortOrders; } /** * Instance of this class was previously passed to frontend controller, so we are now * ready to accept runQuery requests. The query object contains all the important information * about what we should get. * * @param ProductSearchContext $context * @param ProductSearchQuery $query * * @return ProductSearchResult */ public function runQuery( ProductSearchContext $context, ProductSearchQuery $query ) { $result = new ProductSearchResult(); /** * Get currently selected filters. In the query, it's passed as encoded URL string, * we make it an array. All filters in the URL that are no longer valid are removed. */ $facetedSearchFilters = $this->filtersConverter->createFacetedSearchFiltersFromQuery($query); // Initialize the search mechanism $context = $this->module->getContext(); $facetedSearch = $this->searchFactory->build($context); // Add query information into Search $facetedSearch->setQuery($query); // Init the search with the initial population associated with the current filters $facetedSearch->initSearch($facetedSearchFilters); // Request combination IDs if we have some attributes to search by. // If not, we won't use this to let the core select the default combination. if ($this->shouldPassCombinationIds($facetedSearchFilters)) { $facetedSearch->getSearchAdapter()->getInitialPopulation()->addSelectField('id_product_attribute'); $facetedSearch->getSearchAdapter()->addSelectField('id_product_attribute'); } // Load the product searcher, it gets the Adapter through Search object $filterProductSearch = new Filters\Products($facetedSearch); // Get the product associated with the current filter $productsAndCount = $filterProductSearch->getProductByFilters( $query, $facetedSearchFilters ); $result ->setProducts($productsAndCount['products']) ->setTotalProductsCount($productsAndCount['count']) ->setAvailableSortOrders($this->getAvailableSortOrders($query)); // Now let's get the filter blocks associated with the current search. // This will allow user to further filter this list we found. $filterBlockSearch = new Filters\Block( $facetedSearch->getSearchAdapter(), $context, $this->module->getDatabase(), $this->dataAccessor, $query, $this->provider ); // Let's try to get filters from cache, if the controller is supported $filterHash = $this->generateCacheKeyForQuery($query, $facetedSearchFilters); if ($this->module->shouldCacheController($query->getQueryType())) { $filterBlock = $filterBlockSearch->getFromCache($filterHash); } // If not, we regenerate it and cache it if (empty($filterBlock)) { $filterBlock = $filterBlockSearch->getFilterBlock($productsAndCount['count'], $facetedSearchFilters); if ($this->module->shouldCacheController($query->getQueryType())) { $filterBlockSearch->insertIntoCache($filterHash, $filterBlock); } } $facets = $this->filtersConverter->getFacetsFromFilterBlocks( $filterBlock['filters'] ); $this->labelRangeFilters($facets); $this->addEncodedFacetsToFilters($facets); $this->hideUselessFacets($facets, (int) $result->getTotalProductsCount()); $facetCollection = new FacetCollection(); $nextMenu = $facetCollection->setFacets($facets); $result->setFacetCollection($nextMenu); $facetFilters = $this->urlSerializer->getActiveFacetFiltersFromFacets($facets); $result->setEncodedFacets($this->urlSerializer->serialize($facetFilters)); return $result; } /** * Generate unique cache hash to store blocks in cache * * @param ProductSearchQuery $query * @param array $facetedSearchFilters * * @return string */ private function generateCacheKeyForQuery(ProductSearchQuery $query, array $facetedSearchFilters) { $context = $this->module->getContext(); $filterKey = $query->getQueryType(); if ($query->getQueryType() == 'category') { $filterKey .= $query->getIdCategory(); } elseif ($query->getQueryType() == 'manufacturer') { $filterKey .= $query->getIdManufacturer(); } elseif ($query->getQueryType() == 'supplier') { $filterKey .= $query->getIdSupplier(); } $filterHash = md5( sprintf( '%d-%d-%d-%s-%d-%s', (int) $context->shop->id, (int) $context->currency->id, (int) $context->language->id, $filterKey, (int) $context->country->id, serialize($facetedSearchFilters) ) ); return $filterHash; } /** * Renders an product search result. * * @param ProductSearchContext $context * @param ProductSearchResult $result * * @return string the HTML of the facets */ public function renderFacets(ProductSearchContext $context, ProductSearchResult $result) { list($activeFilters, $displayedFacets, $facetsVar) = $this->prepareActiveFiltersForRender($context, $result); // No need to render without facets if (empty($facetsVar)) { return ''; } $this->module->getContext()->smarty->assign( [ 'show_quantities' => Configuration::get('PS_LAYERED_SHOW_QTIES'), 'facets' => $facetsVar, 'js_enabled' => $this->module->isAjax(), 'displayedFacets' => $displayedFacets, 'activeFilters' => $activeFilters, 'sort_order' => $result->getCurrentSortOrder()->toString(), 'clear_all_link' => $this->updateQueryString( [ 'q' => null, 'page' => null, ] ), ] ); return $this->module->fetch( 'module:ps_facetedsearch/views/templates/front/catalog/facets.tpl' ); } /** * Renders an product search result of active filters. * * @param ProductSearchContext $context * @param ProductSearchResult $result * * @return string the HTML of the facets */ public function renderActiveFilters(ProductSearchContext $context, ProductSearchResult $result) { list($activeFilters) = $this->prepareActiveFiltersForRender($context, $result); $this->module->getContext()->smarty->assign( [ 'activeFilters' => $activeFilters, 'clear_all_link' => $this->updateQueryString( [ 'q' => null, 'page' => null, ] ), ] ); return $this->module->fetch( 'module:ps_facetedsearch/views/templates/front/catalog/active-filters.tpl' ); } /** * Prepare active filters for renderer. * * @param ProductSearchContext $context * @param ProductSearchResult $result * * @return array|null */ private function prepareActiveFiltersForRender(ProductSearchContext $context, ProductSearchResult $result) { $facetCollection = $result->getFacetCollection(); // not all search providers generate menus if (empty($facetCollection)) { return null; } $facetsVar = array_map( [$this, 'prepareFacetForTemplate'], $facetCollection->getFacets() ); $displayedFacets = []; $activeFilters = []; foreach ($facetsVar as $idx => $facet) { // Remove undisplayed facets if (!empty($facet['displayed'])) { $displayedFacets[] = $facet; } // Check if a filter is active foreach ($facet['filters'] as $filter) { if ($filter['active']) { $activeFilters[] = $filter; } } } return [ $activeFilters, $displayedFacets, $facetsVar, ]; } /** * Converts a Facet to an array with all necessary * information for templating. * * @param Facet $facet * * @return array ready for templating */ protected function prepareFacetForTemplate(Facet $facet) { $facetsArray = $facet->toArray(); foreach ($facetsArray['filters'] as &$filter) { $filter['facetLabel'] = $facet->getLabel(); if ($filter['nextEncodedFacets'] || $facet->getWidgetType() === 'slider') { $filter['nextEncodedFacetsURL'] = $this->updateQueryString([ 'q' => $filter['nextEncodedFacets'], 'page' => null, ]); } else { $filter['nextEncodedFacetsURL'] = $this->updateQueryString([ 'q' => null, ]); } } unset($filter); return $facetsArray; } /** * Add a label associated with the facets * * @param array $facets */ private function labelRangeFilters(array $facets) { $context = $this->module->getContext(); foreach ($facets as $facet) { if (!in_array($facet->getType(), Filters\Converter::RANGE_FILTERS)) { continue; } foreach ($facet->getFilters() as $filter) { $filterValue = $filter->getValue(); $min = empty($filterValue[0]) ? $facet->getProperty('min') : $filterValue[0]; $max = empty($filterValue[1]) ? $facet->getProperty('max') : $filterValue[1]; if ($facet->getType() === 'weight') { $unit = Configuration::get('PS_WEIGHT_UNIT'); $filter->setLabel( sprintf( '%1$s %2$s - %3$s %4$s', $context->getCurrentLocale()->formatNumber($min), $unit, $context->getCurrentLocale()->formatNumber($max), $unit ) ); } elseif ($facet->getType() === 'price') { $filter->setLabel( sprintf( '%1$s - %2$s', $context->getCurrentLocale()->formatPrice($min, $context->currency->iso_code), $context->getCurrentLocale()->formatPrice($max, $context->currency->iso_code) ) ); } } } } /** * This method generates a URL stub for each filter inside the given facets * and assigns this stub to the filters. * The URL stub is called 'nextEncodedFacets' because it is used * to generate the URL of the search once a filter is activated. */ private function addEncodedFacetsToFilters(array $facets) { // first get the currently active facetFilter in an array $originalFacetFilters = $this->urlSerializer->getActiveFacetFiltersFromFacets($facets); foreach ($facets as $facet) { $activeFacetFilters = $originalFacetFilters; // If only one filter can be selected, we keep track of // the current active filter to disable it before generating the url stub // and not select two filters in a facet that can have only one active filter. if (!$facet->isMultipleSelectionAllowed() && !$facet->getProperty('range')) { foreach ($facet->getFilters() as $filter) { if ($filter->isActive()) { // we have a currently active filter is the facet, remove it from the facetFilter array $activeFacetFilters = $this->urlSerializer->removeFilterFromFacetFilters( $originalFacetFilters, $filter, $facet ); break; } } } foreach ($facet->getFilters() as $filter) { // toggle the current filter if ($filter->isActive() || $facet->getProperty('range')) { $facetFilters = $this->urlSerializer->removeFilterFromFacetFilters( $activeFacetFilters, $filter, $facet ); } else { $facetFilters = $this->urlSerializer->addFilterToFacetFilters( $activeFacetFilters, $filter, $facet ); } // We've toggled the filter, so the call to serialize // returns the "URL" for the search when user has toggled // the filter. $filter->setNextEncodedFacets( $this->urlSerializer->serialize($facetFilters) ); } } } /** * Remove the facet when there's only 1 result. * Keep facet status when it's a slider. * Keep facet status if it's a availability or extras facet. * * @param array $facets * @param int $totalProducts */ private function hideUselessFacets(array $facets, $totalProducts) { foreach ($facets as $facet) { // If the facet is a slider type, we hide it ONLY if the MIN and MAX value match if ($facet->getWidgetType() === 'slider') { $facet->setDisplayed( $facet->getProperty('min') != $facet->getProperty('max') ); continue; } // Now the rest of facets - we apply this logic $totalFacetProducts = 0; $usefulFiltersCount = 0; foreach ($facet->getFilters() as $filter) { if ($filter->getMagnitude() > 0 && $filter->isDisplayed()) { $totalFacetProducts += $filter->getMagnitude(); ++$usefulFiltersCount; } } // We display the facet in several cases $facet->setDisplayed( // If there are two filters available $usefulFiltersCount > 1 || // There is only one filter available, but it furhter reduces the product selection ( count($facet->getFilters()) === 1 && $totalFacetProducts < $totalProducts && $usefulFiltersCount > 0 ) || // If there is only one filter, but it's availability or extras filter - we want this one to be displayed all the time ($usefulFiltersCount === 1 && ($facet->getType() == 'availability' || $facet->getType() == 'extras')) ); // Other cases - hidden by default } } /** * Generate a URL corresponding to the current page but * with the query string altered. * * Params from $extraParams that have a null value are stripped, * and other params are added. Params not in $extraParams are unchanged. */ private function updateQueryString(array $extraParams = []) { $uriWithoutParams = explode('?', $_SERVER['REQUEST_URI'])[0]; $url = Tools::getCurrentUrlProtocolPrefix() . $_SERVER['HTTP_HOST'] . $uriWithoutParams; $params = []; $paramsFromUri = ''; if (strpos($_SERVER['REQUEST_URI'], '?') !== false) { $paramsFromUri = explode('?', $_SERVER['REQUEST_URI'])[1]; } parse_str($paramsFromUri, $params); foreach ($extraParams as $key => $value) { if (null === $value) { // Force clear param if null value is passed unset($params[$key]); } else { $params[$key] = $value; } } foreach ($params as $key => $param) { if (null === $param || '' === $param) { unset($params[$key]); } } $queryString = str_replace('%2F', '/', http_build_query($params, '', '&')); return $url . ($queryString ? "?$queryString" : ''); } /** * Checks if we should return information about combinations to the core * * @param array $facetedSearchFilters filters passed in the query and parsed by our module * * @return bool if should add attributes to the select */ private function shouldPassCombinationIds(array $facetedSearchFilters) { return !empty($facetedSearchFilters['id_attribute_group']); } }