AJAX (Asynchronous Javascript and XML) is a way of getting a web page to communicate with a server, updating its content without reloading the page. In WooCommerce AJAX enables us to add products directly to the shopping cart, customize products on the fly, filter product lists, and much more.
In this tutorial we’re going to build a product “live search” plugin, with a product category filter and keyword input. All, of course, powered by AJAX.
Our plugin will give us a custom widget which can then be placed anywhere in the WooCommerce store. It will look like this (the aesthetics will change depending on the WordPress theme you’re using):
1. Create the Plugin Folder
Begin by creating a folder called “product-search” and the main php file within it “product-search.php”. Open the file and add the following header comment, changing the pertinent details to your own:
/* Plugin Name: Woocommerce AJAX product search Plugin URI: https://www.enovathemes.com Description: Ajax product search for WooCommerce Author: Enovathemes Version: 1.0 Author URI: http://enovathemes.com */ if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly }
Here we describe what our plugin is and what it does. I won’t cover plugin development in details as that”s beyond the scope of this tutorial, but if you are new to plugin development, I highly recommend taking a look at this beginner’s course:
2. Plan Our Plugin Development
So here is our plan: we will have a search input with a select element to define the product category. All this will be packed inside a widget. Users will be able to search for a keyword within a specific product category.
Whenever the user enters a product keyword or product SKU we will make an AJAX request to query products that match the category (if defined), and contain the given keyword in the title, content, or match the given SKU. The user will be presented with a list of search results.
Our next step is to enqueue the plugin style and script files.
3. Enqueue Plugin Files
Add the following code after the plugin intro:
function search_plugin_scripts_styles(){ if (class_exists("Woocommerce")) { wp_enqueue_style( 'search-style', plugins_url('/css/style.css', __FILE__ ), array(), '1.0.0' ); wp_register_script( 'search-main', plugins_url('/js/main.js', __FILE__ ), array('jquery'), '', true); wp_localize_script( 'search-main', 'opt', array( 'ajaxUrl' => admin_url('admin-ajax.php'), 'noResults' => esc_html__( 'No products found', 'textdomain' ), ) ); } } add_action( 'wp_enqueue_scripts', 'search_plugin_scripts_styles' );
Make sure you create corresponding folders for styles and scripts (css and js folders) and the corresponding files (style.css and main.js).
For the main.js file we will need to pass some parameters with the wp_localize_script
function. These parameters give us the AJAX url and the “no results text” so we don’t have to hardcode them into our script.
4. Get Product Category Taxonomy with Hierarchy
Next we will need to collect and cache all the product categories with hierarchy. This will be used for the category select options.
This task has 4 steps:
- Get product category taxonomy with hierarchy
- List product category taxonomy with hierarchy as select options
- Cache the product category taxonomy results
- Delete product categories transient (cache) on term edit and post save
Get Taxonomy
Here I’ve created a recursive function that collects the given taxonomy terms with the parent child relationship:
function get_taxonomy_hierarchy( $taxonomy, $parent = 0, $exclude = 0) { $taxonomy = is_array( $taxonomy ) ? array_shift( $taxonomy ) : $taxonomy; $terms = get_terms( $taxonomy, array( 'parent' => $parent, 'hide_empty' => false, 'exclude' => $exclude) ); $children = array(); foreach ( $terms as $term ){ $term->children = get_taxonomy_hierarchy( $taxonomy, $term->term_id, $exclude); $children[ $term->term_id ] = $term; } return $children; }
List Product Categories as Select Options
Next we need to list the collected terms with another recursive function. It creates the option
and optgroup
based HTML structure:
function list_taxonomy_hierarchy_no_instance( $taxonomies) { ?> <?php foreach ( $taxonomies as $taxonomy ) { ?> <?php $children = $taxonomy->children; ?> <option value="<?php echo $taxonomy->term_id; ?>"><?php echo $taxonomy->name; ?></option> <?php if (is_array($children) && !empty($children)): ?> <optgroup> <?php list_taxonomy_hierarchy_no_instance($children); ?> </optgroup> <?php endif ?> <?php } ?> <?php }
Cache the Product Category Results
Queried results need to be cached so as not to slow down the filter render process. So here we need to create a transient for product categories. I won’t describe in detail the Transients API, but if you are new to the topic I highly recommend reading these amazing introduction tutorials:
-
Theme DevelopmentGetting Started With The WordPress Transients API, Part 1
-
PluginsGetting Started with the WordPress Transient API, Part 2
For now, here is the product category transient:
function get_product_categories_hierarchy() { if ( false === ( $categories = get_transient( 'product-categories-hierarchy' ) ) ) { $categories = get_taxonomy_hierarchy( 'product_cat', 0, 0); // do not set an empty transient - should help catch private or empty accounts. if ( ! empty( $categories ) ) { $categories = base64_encode( serialize( $categories ) ); set_transient( 'product-categories-hierarchy', $categories, apply_filters( 'null_categories_cache_time', 0 ) ); } } if ( ! empty( $categories ) ) { return unserialize( base64_decode( $categories ) ); } else { return new WP_Error( 'no_categories', esc_html__( 'No categories.', 'textdomain' ) ); } }
Delete Product Categories Transient (Cache) on Term Edit and Post Save
Finally we need to delete the transient whenever a user updates or creates a product category, or updates/creates the product itself.
function edit_product_term($term_id, $tt_id, $taxonomy) { $term = get_term($term_id,$taxonomy); if (!is_wp_error($term) && is_object($term)) { $taxonomy = $term->taxonomy; if ($taxonomy == "product_cat") { delete_transient( 'product-categories-hierarchy' ); } } } function delete_product_term($term_id, $tt_id, $taxonomy, $deleted_term) { if (!is_wp_error($deleted_term) && is_object($deleted_term)) { $taxonomy = $deleted_term->taxonomy; if ($taxonomy == "product_cat") { delete_transient( 'product-categories-hierarchy' ); } } } add_action( 'create_term', 'edit_product_term', 99, 3 ); add_action( 'edit_term', 'edit_product_term', 99, 3 ); add_action( 'delete_term', 'delete_product_term', 99, 4 ); add_action( 'save_post', 'save_post_action', 99, 3); function save_post_action( $post_id ){ if( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return; if (!current_user_can( 'edit_page', $post_id ) ) return; $post_info = get_post($post_id); if (!is_wp_error($post_info) && is_object($post_info)) { $content = $post_info->post_content; $post_type = $post_info->post_type; if ($post_type == "product"){ delete_transient( 'enovathemes-product-categories' ); } } }
We will add actions for create_term
, edit_term
, delete_term
and save_post
5. Create the Widget
Now it’s time to create the widget itself. I won’t describe in detail the widget creation process, but if you need to get up to speed I recommend this tutorial:
For now, add the following code to create the widget:
add_action('widgets_init', 'register_product_search_widget'); function register_product_search_widget(){ register_widget( 'Enovathemes_Addons_WP_Product_Search' ); } class Enovathemes_Addons_WP_Product_Search extends WP_Widget { public function __construct() { parent::__construct( 'product_search_widget', esc_html__('* Product ajax search', 'textdomain'), array( 'description' => esc_html__('Product ajax search', 'textdomain')) ); } public function widget( $args, $instance) { wp_enqueue_script('search-main'); extract($args); $title = apply_filters( 'widget_title', $instance['title'] ); echo $before_widget; if ( ! empty( $title ) ){echo $before_title . $title . $after_title;} ?> <div class="product-search"> <form name="product-search" method="POST"> <?php $categories = get_product_categories_hierarchy(); ?> <?php if ($categories): ?> <select name="category" class="category"> <option class="default" value=""><?php echo esc_html__( 'Select a category', 'textdomain' ); ?></option> <?php list_taxonomy_hierarchy_no_instance( $categories); ?> </select> <?php endif ?> <div class="search-wrapper"> <input type="search" name="search" class="search" placeholder="<?php echo esc_html__( 'Search for product...', 'textdomain' ); ?>" value=""> <?php echo file_get_contents(plugins_url( 'images/loading.svg', __FILE__ )); ?> </div> </form> <div class="search-results"></div> </div> <?php echo $after_widget; } public function form( $instance ) { $defaults = array( 'title' => esc_html__('Product search', 'textdomain'), ); $instance = wp_parse_args((array) $instance, $defaults); ?> <div id="<?php echo esc_attr($this->get_field_id( 'widget_id' )); ?>"> <p> <label for="<?php echo $this->get_field_id( 'title' ); ?>"><?php echo esc_html__( 'Title:', 'textdomain' ); ?></label> <input class="widefat <?php echo $this->get_field_id( 'title' ); ?>" name="<?php echo $this->get_field_name( 'title' ); ?>" type="text" value="<?php echo esc_attr( $instance['title'] ); ?>" /> </p> </div> <?php } public function update( $new_instance, $old_instance ) { $instance = $old_instance; $instance['title'] = strip_tags( $new_instance['title'] ); return $instance; } }
Our widget has no options, but does allow you to enter a title. It is a simple form with one search field and a category select. And for the category select we use the function we created earlier:
get_product_categories_hierarchy
and list_taxonomy_hierarchy_no_instance
. Also, we will need an SVG file to denote the loading when making the AJAX query.
For now, if you go to Appearance > Widgets you will see a new widget available, so you can add it to the widget area and see the following on the front end:
Looking awful! Let’s add some styles.
6. Add Some Styles
Open the style.css file and add the following:
.product-search { position: relative; padding: 24px; border-radius: 4px; box-shadow:0px 0px 24px 0px rgba(0, 0, 0, 0.08); border:1px solid #e0e0e0; background: #f5f5f5; } .search-results { display: none; position: absolute; width: 200%; background: #ffffff; padding:12px 24px; border: 1px solid #e0e0e0; z-index: 15; transform: translateY(-1px); } .search-results.active { display: block; } .search-results ul { list-style: none; margin:0 !important; padding: 0 !important; } .search-results ul li { display: block; padding: 12px 0; position: relative; border-bottom: 1px dashed #e0e0e0; } .search-results ul li:last-child { border-bottom: none; } .search-results ul li a { display: table; width: 100%; } .search-results ul li a > * { display: table-cell; vertical-align: top; } .search-results .product-image { width: 72px; max-width: 72px; } .product-data { padding-left: 24px; } .search-results h3 { display: block; } .product-data div:not(.product-categories) { display: inline-block; vertical-align: middle; } .product-data .product-price { position: absolute; top: 12px; right: 0; } .product-data .product-stock { padding: 4px 8px; background: #eeeeee; border-radius: 4px; position: absolute; bottom: 12px; right: 0; } .product-categories > span { display: inline-block; margin-right: 4px; } .product-categories > span:after { content: ","; } .product-categories > span:last-child:after { content: ""; } .product-categories > span:last-child { margin-right:0; } .product-search select { width: 100% !important; min-height: 40px !important; margin-bottom: 16px; } .product-search select, .product-search input { background: #ffffff; border:1px solid #e0e0e0; } .search-wrapper { position: relative; } .search-wrapper input { padding-right: 35px !important; } .search-wrapper svg { position: absolute; top: 10px; right: 10px; width: 20px; height: 20px; fill:#bdbdbd; animation:loading 500ms 0ms infinite normal linear; transform-origin: center; opacity: 0; } .search-wrapper.loading svg { opacity:1; } @keyframes loading { from {transform: rotate(0deg);} to {transform: rotate(360deg);} }
Now refresh the browser, (don’t forget about browser cache) and your widget should look much better:
For now, it does nothing at all, so let’s apply some functions.
7. Add Search Functions
Open the main.js file; here we’ll create our core search functionality.
The idea is simple: we will add event listeners to search input keyup (typing) and select field change. Whenever any of these events fire we will make an AJAX request to send the keyword, query products based on the keyword, and output the given results.
Add the following code to the main.js file:
(function($){ "use strict"; $('form[name="product-search"]').each(function(){ var form = $(this), search = form.find('.search'), category = form.find('.category'), currentQuery = '', timeout = false; category.on('change',function(){ currentQuery = ''; var query = search.val(); productSearch(form,query,currentQuery,timeout); }); search.keyup(function(){ var query = $(this).val(); productSearch(form,query,currentQuery,timeout); }); }); })(jQuery);
Here we’ve defined some required variables and added event listeners to the select search. As you can see both events trigger the same function productSearch
that has several parameters:
- form
- query
- currentQuery
- timeout;
productSearch Function
We don’t actually have that function yet, so the search won’t work for now, so let’s create that function. Add the following code right before the earlier one.
function productSearch(form,query,currentQuery,timeout){ var search = form.find('.search'), category = form.find('.category'); form.next('.search-results').html('').removeClass('active'); query = query.trim(); if (query.length >= 3) { if (timeout) { clearTimeout(timeout); } form.next('.search-results').removeClass('empty'); search.parent().addClass('loading'); if (query != currentQuery) { timeout = setTimeout(function() { $.ajax({ url:opt.ajaxUrl, type: 'post', data: { action: 'search_product', keyword: query, category: category.val() }, success: function(data) { currentQuery = query; search.parent().removeClass('loading'); if (!form.next('.search-results').hasClass('empty')) { if (data.length) { form.next('.search-results').html('<ul>'+data+'</ul>').addClass('active'); } else { form.next('.search-results').html(opt.noResults).addClass('active'); } } clearTimeout(timeout); timeout = false; } }); }, 500); } } else { search.parent().removeClass('loading'); form.next('.search-results').empty().removeClass('active').addClass('empty'); clearTimeout(timeout); timeout = false; } }
In this function we first make sure that our keyword has at least 3 characters in it and doesn’t have and spaces
Next, if our keyword length is more or less equal to 3 characters we add the loading class to the search field parent wrapper–this is required to run the CSS animation while we are making our AJAX request.
And here we will need to check the keyword entered doesn’t equal the current keyword, to avoid double AJAX requests on the same keyword. Next we set the Timeout for 500ms and make an AJAX request. With the request we pass the keyword, category and the AJAX request action search_product
.
As we don’t have search_product
action yet we will get an internal server error when making the AJAX. So let’s now create that action.
search_product Action
Open the main product-search.php file and at the very bottom add the following code:
function search_product() { global $wpdb, $woocommerce; if (isset($_POST['keyword']) && !empty($_POST['keyword'])) { $keyword = $_POST['keyword']; if (isset($_POST['category']) && !empty($_POST['category'])) { $category = $_POST['category']; $querystr = "SELECT DISTINCT * FROM $wpdb->posts AS p LEFT JOIN $wpdb->term_relationships AS r ON (p.ID = r.object_id) INNER JOIN $wpdb->term_taxonomy AS x ON (r.term_taxonomy_id = x.term_taxonomy_id) INNER JOIN $wpdb->terms AS t ON (r.term_taxonomy_id = t.term_id) WHERE p.post_type IN ('product') AND p.post_status = 'publish' AND x.taxonomy = 'product_cat' AND ( (x.term_id = {$category}) OR (x.parent = {$category}) ) AND ( (p.ID IN (SELECT post_id FROM $wpdb->postmeta WHERE meta_key = '_sku' AND meta_value LIKE '%{$keyword}%')) OR (p.post_content LIKE '%{$keyword}%') OR (p.post_title LIKE '%{$keyword}%') ) ORDER BY t.name ASC, p.post_date DESC;"; } else { $querystr = "SELECT DISTINCT $wpdb->posts.* FROM $wpdb->posts, $wpdb->postmeta WHERE $wpdb->posts.ID = $wpdb->postmeta.post_id AND ( ($wpdb->postmeta.meta_key = '_sku' AND $wpdb->postmeta.meta_value LIKE '%{$keyword}%') OR ($wpdb->posts.post_content LIKE '%{$keyword}%') OR ($wpdb->posts.post_title LIKE '%{$keyword}%') ) AND $wpdb->posts.post_status = 'publish' AND $wpdb->posts.post_type = 'product' ORDER BY $wpdb->posts.post_date DESC"; } $query_results = $wpdb->get_results($querystr); if (!empty($query_results)) { $output = ''; foreach ($query_results as $result) { $price = get_post_meta($result->ID,'_regular_price'); $price_sale = get_post_meta($result->ID,'_sale_price'); $currency = get_woocommerce_currency_symbol(); $sku = get_post_meta($result->ID,'_sku'); $stock = get_post_meta($result->ID,'_stock_status'); $categories = wp_get_post_terms($result->ID, 'product_cat'); $output .= '<li>'; $output .= '<a href="'.get_post_permalink($result->ID).'">'; $output .= '<div class="product-image">'; $output .= '<img src="'.esc_url(get_the_post_thumbnail_url($result->ID,'thumbnail')).'">'; $output .= '</div>'; $output .= '<div class="product-data">'; $output .= '<h3>'.$result->post_title.'</h3>'; if (!empty($price)) { $output .= '<div class="product-price">'; $output .= '<span class="regular-price">'.$price[0].'</span>'; if (!empty($price_sale)) { $output .= '<span class="sale-price">'.$price_sale[0].'</span>'; } $output .= $currency; $output .= '</div>'; } if (!empty($categories)) { $output .= '<div class="product-categories">'; foreach ($categories as $category) { if ($category->parent) { $parent = get_term_by('id',$category->parent,'product_cat'); $output .= '<span>'.$parent->name.'</span>'; } $output .= '<span>'.$category->name.'</span>'; } $output .= '</div>'; } if (!empty($sku)) { $output .= '<div class="product-sku">'.esc_html__( 'SKU:', 'textdomain' ).' '.$sku[0].'</div>'; } if (!empty($stock)) { $output .= '<div class="product-stock">'.$stock[0].'</div>'; } $output .= '</div>'; $output .= '</a>'; $output .= '</li>'; } if (!empty($output)) { echo $output; } } } die(); } add_action( 'wp_ajax_search_product', 'search_product' ); add_action( 'wp_ajax_nopriv_search_product', 'search_product' );
For now, pay attention to the add_action
part of the code. See the action names, with the prefixes wp_ajax_
and wp_ajax_nopriv_
? We must use the same action names as we specified in the main.js file–search_product
.
Now the Action Core
Here we are using the $wpdb
query method to speed up the query process. I am no MySQL guru, so I guess the professionals can make it more optimized, but for our task it is good enough and working as expected.
Here we first check if any category was specified, then query results under that specific category. If no category is specified we perform a regular product query that contains the keyword in the title, or the content, or matches the SKU. And if we have the results we create the output based on the queried results.
Now back to the main.js. If our AJAX request is successful we return the output data in the list and append to the search-results empty div. All that remains is to clear the timeout.
That’s it! An effective and powerful product AJAX search. Now if you go to the front-end, reload the page (don’t forget about the browser caching) you can search for products and see the widget in action.
Conclusion
You are free to use this plugin in your projects, both commercial and non-commercial. I hope you like and if you have any ideas you are free to write in comments section. You can download the plugin from GitHub. And here is the plugin demo. Thanks for reading!
No comments:
Post a Comment