';
}
/**
* Renders the keyword search box. Uses a custom param (aumnexcart_s) rather
* than the reserved `s` so WordPress keeps treating the request as a
* product archive instead of switching to a site-wide search page;
* the keyword is injected into the query as `s` server-side.
*/
private function render_search_filter() {
$current = isset( $_GET['aumnexcart_s'] ) ? sanitize_text_field( wp_unslash( $_GET['aumnexcart_s'] ) ) : '';
echo '
';
echo '';
echo '';
echo '
';
}
/**
* Renders the sort dropdown. Options come from get_sort_options()
* so the front-end control and the query builders stay in sync.
*/
private function render_sort_filter() {
$current = isset( $_GET['aumnexcart_orderby'] ) ? sanitize_key( wp_unslash( $_GET['aumnexcart_orderby'] ) ) : '';
echo '
';
echo '';
echo '';
echo '
';
}
/**
* The canonical sort option list (value => label). The first entry
* is the implicit default (newest first).
*
* @return array
*/
public static function get_sort_options() {
return array(
'date_desc' => __( 'Newest', 'aumnexcart' ),
'date_asc' => __( 'Oldest', 'aumnexcart' ),
'title_asc' => __( 'Name: A–Z', 'aumnexcart' ),
'title_desc' => __( 'Name: Z–A', 'aumnexcart' ),
'price_asc' => __( 'Price: low to high', 'aumnexcart' ),
'price_desc' => __( 'Price: high to low', 'aumnexcart' ),
);
}
/**
* Resolves a sort key into query directives. Returns null for an
* unknown/default key (caller leaves the query's natural order).
* Price uses a sentinel 'price' orderby the caller expands into a
* meta clause so unpriced products are kept, not filtered out.
*
* @param string $key
* @return array|null { orderby, order }
*/
public static function resolve_sort( $key ) {
$map = array(
'date_asc' => array( 'orderby' => 'date', 'order' => 'ASC' ),
'title_asc' => array( 'orderby' => 'title', 'order' => 'ASC' ),
'title_desc' => array( 'orderby' => 'title', 'order' => 'DESC' ),
'price_asc' => array( 'orderby' => 'price', 'order' => 'ASC' ),
'price_desc' => array( 'orderby' => 'price', 'order' => 'DESC' ),
);
return isset( $map[ $key ] ) ? $map[ $key ] : null;
}
/**
* Applies search + sort onto a WP_Query args array, returning the
* modified array. Shared by the AJAX handler and the URL-mode
* pre_get_posts adjuster so both paths behave identically.
*
* @param array $args WP_Query args (may already carry meta_query).
* @param string $search Raw keyword ('' to skip).
* @param string $sort_key Sort option key ('' for default).
* @return array
*/
public static function apply_search_sort( array $args, $search, $sort_key ) {
if ( '' !== $search ) {
$args['s'] = $search;
}
$sort = self::resolve_sort( $sort_key );
if ( ! $sort ) {
return $args;
}
if ( 'price' === $sort['orderby'] ) {
$meta_query = isset( $args['meta_query'] ) && is_array( $args['meta_query'] ) ? $args['meta_query'] : array();
/* OR clause keeps products with no price meta (text/hidden
* types) in the result; they just sort to the end. The named
* leaf 'aumnexcart_price_lookup' is what orderby references. */
$meta_query[] = array(
'relation' => 'OR',
'aumnexcart_price_lookup' => array(
'key' => AumNexCart_Meta::KEY_PRICE_MIN,
'type' => 'NUMERIC',
'compare' => 'EXISTS',
),
array(
'key' => AumNexCart_Meta::KEY_PRICE_MIN,
'compare' => 'NOT EXISTS',
),
);
$args['meta_query'] = $meta_query;
$args['orderby'] = array(
'aumnexcart_price_lookup' => $sort['order'],
'date' => 'DESC',
);
} else {
$args['orderby'] = $sort['orderby'];
$args['order'] = $sort['order'];
}
return $args;
}
/**
* Builds a reset URL that strips all filter parameters but
* keeps the current page context (taxonomy archive vs main
* post-type archive).
*
* @return string
*/
private function get_reset_url() {
$qo = get_queried_object();
if ( $qo instanceof WP_Term && AumNexCart_Post_Type::TAXONOMY === $qo->taxonomy ) {
return get_term_link( $qo );
}
return get_post_type_archive_link( AumNexCart_Post_Type::POST_TYPE );
}
/* ----------------------------------------------------------------
* Attribute value collection
* ---------------------------------------------------------------- */
/**
* Returns the distinct values stored under a shadow meta key,
* cached in a transient. The cache is invalidated whenever any
* product is saved.
*
* @param string $slug
* @return array
*/
public function collect_attribute_values( $slug ) {
$cache_key = 'aumnexcart_attr_values_' . $slug;
$cached = get_transient( $cache_key );
if ( false !== $cached && is_array( $cached ) ) {
return $cached;
}
global $wpdb;
$meta_key = AumNexCart_Meta::KEY_ATTR_SHADOW_PREFIX . $slug;
$values = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT meta_value FROM {$wpdb->postmeta}
WHERE meta_key = %s AND meta_value != ''
ORDER BY meta_value ASC",
$meta_key
)
);
$values = array_values( array_filter( array_map( 'strval', (array) $values ) ) );
set_transient( $cache_key, $values, HOUR_IN_SECONDS );
return $values;
}
/**
* Static helper that clears all attribute value caches. Called
* from save_post and from settings save when the filterable
* list changes.
*/
public static function invalidate_value_cache() {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE %s OR option_name LIKE %s",
$wpdb->esc_like( '_transient_aumnexcart_attr_values_' ) . '%',
$wpdb->esc_like( '_transient_timeout_aumnexcart_attr_values_' ) . '%'
)
);
}
/* ----------------------------------------------------------------
* AJAX handler
* ---------------------------------------------------------------- */
/**
* Handles ?action=aumnexcart_filter requests. Builds the same
* WP_Query the archive page would build and returns rendered
* grid + pagination HTML fragments.
*/
public function ajax_handler() {
check_ajax_referer( self::AJAX_ACTION, 'nonce' );
$args = array(
'post_type' => AumNexCart_Post_Type::POST_TYPE,
'posts_per_page' => (int) AumNexCart_Settings::get( 'products_per_page', 12 ),
'paged' => isset( $_POST['paged'] ) ? max( 1, absint( $_POST['paged'] ) ) : 1,
);
/* Category */
$cat_slug = isset( $_POST[ AumNexCart_Post_Type::TAXONOMY ] )
? sanitize_text_field( wp_unslash( $_POST[ AumNexCart_Post_Type::TAXONOMY ] ) )
: '';
if ( '' !== $cat_slug ) {
$args['tax_query'] = array(
array(
'taxonomy' => AumNexCart_Post_Type::TAXONOMY,
'field' => 'slug',
'terms' => $cat_slug,
),
);
}
/* Attributes via shadow meta */
$meta_query = array();
$filterable = AumNexCart_Settings::get( 'filterable_attributes', array() );
if ( is_array( $filterable ) ) {
foreach ( $filterable as $key ) {
$slug = AumNexCart_Meta::attribute_key_to_slug( $key );
if ( '' === $slug ) {
continue;
}
$param = 'attr_' . $slug;
if ( ! empty( $_POST[ $param ] ) ) {
$value = sanitize_text_field( wp_unslash( $_POST[ $param ] ) );
$meta_query[] = array(
'key' => AumNexCart_Meta::KEY_ATTR_SHADOW_PREFIX . $slug,
'value' => $value,
'compare' => '=',
);
}
}
}
/* Price range */
if ( AumNexCart_Settings::get( 'enable_price_filter', 0 ) ) {
if ( ! empty( $_POST['min_price'] ) ) {
$meta_query[] = array(
'key' => AumNexCart_Meta::KEY_PRICE_MIN,
'value' => (float) sanitize_text_field( wp_unslash( $_POST['min_price'] ) ),
'type' => 'NUMERIC',
'compare' => '>=',
);
}
if ( ! empty( $_POST['max_price'] ) ) {
$meta_query[] = array(
'key' => AumNexCart_Meta::KEY_PRICE_MAX,
'value' => (float) sanitize_text_field( wp_unslash( $_POST['max_price'] ) ),
'type' => 'NUMERIC',
'compare' => '<=',
);
}
}
if ( ! empty( $meta_query ) ) {
$args['meta_query'] = $meta_query;
}
$search = isset( $_POST['aumnexcart_s'] ) ? sanitize_text_field( wp_unslash( $_POST['aumnexcart_s'] ) ) : '';
$sort_key = isset( $_POST['aumnexcart_orderby'] ) ? sanitize_key( wp_unslash( $_POST['aumnexcart_orderby'] ) ) : '';
$args = self::apply_search_sort( $args, $search, $sort_key );
$q = new WP_Query( $args );
$grid_html = $this->render_grid( $q );
$pag_html = $this->render_pagination( $q );
wp_send_json_success(
array(
'grid' => $grid_html,
'pagination' => $pag_html,
'found' => (int) $q->found_posts,
)
);
}
/**
* Renders the grid HTML for a query, reusing the same markup as
* the archive template so visual styling is identical.
*
* @param WP_Query $q
* @return string
*/
private function render_grid( $q ) {
ob_start();
if ( $q->have_posts() ) {
echo '