613 lines
22 KiB
PHP
613 lines
22 KiB
PHP
<?php
|
|
/**
|
|
* Copyright since 2007 PrestaShop SA and Contributors
|
|
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
|
|
*
|
|
* NOTICE OF LICENSE
|
|
*
|
|
* This source file is subject to the Academic Free License 3.0 (AFL-3.0)
|
|
* that is bundled with this package in the file LICENSE.md.
|
|
* It is also available through the world-wide-web at this URL:
|
|
* https://opensource.org/licenses/AFL-3.0
|
|
* If you did not receive a copy of the license and are unable to
|
|
* obtain it through the world-wide-web, please send an email
|
|
* to license@prestashop.com so we can send you a copy immediately.
|
|
*
|
|
* @author PrestaShop SA <contact@prestashop.com>
|
|
* @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']);
|
|
}
|
|
}
|