Ticket 1540800: “Since I limited the shop page to certain collections, the filters bring up other brands as well.”

What is happening

When a product list is limited to certain collections, and a shopper then picks a collection in the filter, SureCart joins the two with OR instead of AND. So the shopper gets the products in your list plus every product in the collection they clicked. That is why brands that are not in your shop suddenly appear.

It is in the plugin code: the query group is built with relation => OR in ProductListBlock.php. It only shows up once a list is limited to collections, which is why it was fine when your shop page showed all products.

See it on this test store

Both pages below run the same shortcode, limited to two collections: Parts (5 products) and Servicing (3), so 8 products in total.

  • Before (stock SureCart): zz-1540800-r6-filter-before. Open the Filter dropdown and pick Kits, which is not one of the two collections. The list goes from 8 products to 12: the 4 Kits products were added. Direct link to that result.
  • After (with the fix below): zz-1540800-r6-filter-after. Picking Servicing narrows 8 products down to 3. Direct link. The filter only offers the collections that are actually in this list, so nobody can pick one that would return an empty page.

Three ways to fix it

Option 1, no extra code: do not limit the shop page at all. If the reason you limited it was to control the order, show_first already does that. Use the all-products shortcode with show_first: the products you choose still come first, and the filters behave exactly as they did before.

[sc_product_list show_first="FLAKE-ICE-ID,PPM-ID" columns="4" limit="12" pagination_enabled="1" ajax_pagination="1"]

Live example: zz-1540800-r5-show-first-whole-catalogue, and the same page filtered to Kits, which correctly shows only the 4 Kits products.

Option 2: keep the shop limited to collections and add the snippet below. The filter then narrows the list. Worth knowing: if a brand has no products inside your chosen collections, filtering by it now correctly returns nothing, so the snippet also hides those brands from the filter list.

Option 3: the two-shops setup. Show everything except the parts collections, with your chosen collections first. This uses the exclude snippet from the first email plus show_first:

[sc_product_list_not_in collection="parts" show_first="flake-ice,servicing-ppm-plans" columns="4" limit="12"]

Live example: zz-1540800-r6-exclude-plus-showfirst. The Parts collection is gone from the products and from the filter list, Kits is shown first, and the filters still narrow.

The snippet for Option 2

Add it the same way as the others: a new PHP snippet in WPCode, Code Snippets or WPCodeBox, set to run everywhere, then activate.

<?php
/**
 * SureCart: make the Collections filter NARROW a product list instead of adding to it.
 *
 * The problem this fixes:
 * when a product list is limited to certain collections (the collection_id setting in the
 * shortcode, or Filters > Collections in the block), and a shopper then picks a collection
 * in the filter, SureCart combines the two with OR. So the shopper gets
 * "the products in your list PLUS every product in the collection they clicked",
 * which looks like other brands appearing out of nowhere.
 * See app/src/Models/Blocks/ProductListBlock.php, where the tax query group is built
 * with 'relation' => 'OR'.
 *
 * With this snippet the two are combined with AND: the filter narrows the list, which is
 * what a shop filter is expected to do. Picking two collections in the filter still means
 * "either of these two", as before.
 *
 * Install: add as a PHP snippet (WPCode, Code Snippets, WPCodeBox) or in your child theme's
 * functions.php. Custom code, not an official part of SureCart. Test on staging or take a backup first.
 */

/**
 * SCNF_TRIM_FILTER_LIST
 * true  = the Collections filter only offers collections that actually have products in this
 *         list, so a shopper cannot pick one that would return an empty page.
 * false = leave the filter list exactly as SureCart builds it.
 */
if ( ! defined( 'SCNF_TRIM_FILTER_LIST' ) ) {
	define( 'SCNF_TRIM_FILTER_LIST', true );
}

/**
 * Optional. Leave this array empty to apply the fix to every product list on the site.
 * To limit it to certain pages, list their slugs: array( 'shop', 'parts-shop' ).
 */
function scnf_only_on_pages() {
	return array();
}

/**
 * Remember the collections the list being rendered right now is limited to.
 * null = no product list is rendering.
 */
function scnf_state( $set = null ) {
	static $current = null;
	if ( func_num_args() ) {
		$current = $set;
	}
	return $current;
}

/**
 * Is the fix allowed on the page we are on?
 */
function scnf_page_allowed() {
	$only = scnf_only_on_pages();
	return empty( $only ) ? true : is_page( $only );
}

/**
 * Turn the list's collection_id setting into WordPress term ids.
 */
function scnf_scope_terms( $collection_id ) {
	static $cache = array();
	$raw = trim( (string) $collection_id );
	if ( '' === $raw ) {
		return array();
	}
	if ( isset( $cache[ $raw ] ) ) {
		return $cache[ $raw ];
	}
	$tokens = array_filter( array_map( 'trim', explode( ',', $raw ) ) );
	$uuids  = array();
	$ids    = array();
	foreach ( $tokens as $token ) {
		if ( preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $token ) ) {
			$uuids[] = $token;
		} else {
			$term = get_term_by( 'slug', sanitize_title( $token ), 'sc_collection' );
			if ( $term && ! is_wp_error( $term ) ) {
				$ids[] = (int) $term->term_id;
			}
		}
	}
	if ( ! empty( $uuids ) ) {
		$terms = get_terms(
			array(
				'taxonomy'   => 'sc_collection',
				'hide_empty' => false,
				'meta_query' => array(
					array(
						'key'     => 'sc_id',
						'value'   => $uuids,
						'compare' => 'IN',
					),
				),
			)
		);
		if ( ! is_wp_error( $terms ) ) {
			foreach ( $terms as $term ) {
				$ids[] = (int) $term->term_id;
			}
		}
	}
	$cache[ $raw ] = array_values( array_unique( $ids ) );
	return $cache[ $raw ];
}

/**
 * 1. A product list starts rendering: note which collections it is limited to.
 */
add_filter(
	'render_block_data',
	function ( $parsed_block ) {
		if ( 'surecart/product-list' !== ( $parsed_block['blockName'] ?? '' ) || ! scnf_page_allowed() ) {
			return $parsed_block;
		}

		$scope = scnf_scope_terms( $parsed_block['attrs']['collection_id'] ?? '' );

		// The block route stores the same thing as WordPress term ids.
		$block_terms = $parsed_block['attrs']['query']['taxQuery']['sc_collection'] ?? array();
		if ( ! empty( $block_terms ) && is_array( $block_terms ) ) {
			$scope = array_merge( $scope, array_map( 'absint', $block_terms ) );
		}

		scnf_state( array_values( array_unique( array_filter( $scope ) ) ) );
		return $parsed_block;
	}
);

/**
 * 2. That product list has finished: forget it, so nothing else on the page is affected.
 */
add_filter(
	'render_block_surecart/product-list',
	function ( $content ) {
		scnf_state( null );
		return $content;
	}
);

/**
 * 3. The actual fix: combine the list's own collections and the shopper's choice with AND.
 */
add_action(
	'pre_get_posts',
	function ( $query ) {
		if ( is_admin() || 'sc_product' !== $query->get( 'post_type' ) || ! scnf_page_allowed() ) {
			return;
		}
		$tax_query = $query->get( 'tax_query' );
		if ( empty( $tax_query ) || ! is_array( $tax_query ) ) {
			return;
		}
		foreach ( $tax_query as $i => $group ) {
			if ( is_array( $group ) && isset( $group['relation'] ) && 'OR' === strtoupper( $group['relation'] ) ) {
				$tax_query[ $i ]['relation'] = 'AND';
			}
		}
		$query->set( 'tax_query', $tax_query );
	},
	20
);

/**
 * 4. Optional: only offer collections that actually have products in this list.
 */
add_filter(
	'get_terms_args',
	function ( $args, $taxonomies ) {
		static $resolving = false;

		if ( ! SCNF_TRIM_FILTER_LIST || $resolving || is_admin() ) {
			return $args;
		}
		if ( ! in_array( 'sc_collection', (array) $taxonomies, true ) ) {
			return $args;
		}
		$scope = scnf_state();
		if ( empty( $scope ) ) {
			return $args; // list is not limited to anything, so every collection is reachable.
		}

		static $cache = array();
		$key = implode( ',', $scope );

		if ( ! isset( $cache[ $key ] ) ) {
			$resolving = true;

			$product_ids = get_posts(
				array(
					'post_type'      => 'sc_product',
					'post_status'    => 'publish',
					'fields'         => 'ids',
					'posts_per_page' => -1,
					'tax_query'      => array(
						array(
							'taxonomy'         => 'sc_collection',
							'field'            => 'term_id',
							'terms'            => $scope,
							'include_children' => false,
						),
					),
				)
			);

			$reachable = empty( $product_ids )
				? $scope
				: wp_get_object_terms( $product_ids, 'sc_collection', array( 'fields' => 'ids' ) );

			$cache[ $key ] = is_wp_error( $reachable ) ? $scope : array_values( array_unique( array_map( 'absint', $reachable ) ) );
			$resolving     = false;
		}

		if ( ! empty( $cache[ $key ] ) ) {
			$args['include'] = isset( $args['include'] ) && ! empty( $args['include'] )
				? array_values( array_intersect( (array) $args['include'], $cache[ $key ] ) )
				: $cache[ $key ];
		}

		return $args;
	},
	20,
	2
);

About the shop being slow

One thing that is worth trying straight away: show fewer products per page. Your shortcode has limit="50", so all 50 products are built and sent in one go.

Measured on this test store, the same shop page, only the limit changed (fresh load, nothing cached):

Roughly half the wait, and the shopper still reaches everything through the page numbers at the bottom. With ajax_pagination="1" the next page loads without a full page reload.

Note that page numbers are only counted correctly when the list is not limited to collections, which is another reason Option 1 or Option 3 is the tidier setup.

Scroll to Top