Advertisement
  1. Web Design
  2. WooCommerce

How to Create an AJAX-Powered Live Product Search Widget for WooCommerce

Scroll to top
Read Time: 16 min

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):

ajax product searchajax product searchajax product search

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:

1
/*

2
    Plugin Name: Woocommerce AJAX product search

3
    Plugin URI: https://www.enovathemes.com

4
    Description: Ajax product search for WooCommerce

5
    Author: Enovathemes

6
    Version: 1.0

7
    Author URI: http://enovathemes.com

8
*/
9
10
if ( ! defined( 'ABSPATH' ) ) {
11
    exit; // Exit if accessed directly

12
}

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:

1
function search_plugin_scripts_styles(){
2
    if (class_exists("Woocommerce")) {
3
4
        wp_enqueue_style( 'search-style', plugins_url('/css/style.css', __FILE__ ), array(), '1.0.0' );
5
        wp_register_script( 'search-main', plugins_url('/js/main.js', __FILE__ ), array('jquery'), '', true);
6
        wp_localize_script(
7
            'search-main',
8
            'opt',
9
            array(
10
                'ajaxUrl'   => admin_url('admin-ajax.php'),
11
                'noResults' => esc_html__( 'No products found', 'textdomain' ),
12
            )
13
        );
14
    }
15
}
16
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:

  1. Get product category taxonomy with hierarchy
  2. List product category taxonomy with hierarchy as select options
  3. Cache the product category taxonomy results
  4. 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:

1
function get_taxonomy_hierarchy( $taxonomy, $parent = 0, $exclude = 0) {
2
    $taxonomy = is_array( $taxonomy ) ? array_shift( $taxonomy ) : $taxonomy;
3
    $terms = get_terms( $taxonomy, array( 'parent' => $parent, 'hide_empty' => false, 'exclude' => $exclude) );
4
5
    $children = array();
6
    foreach ( $terms as $term ){
7
        $term->children = get_taxonomy_hierarchy( $taxonomy, $term->term_id, $exclude);
8
        $children[ $term->term_id ] = $term;
9
    }
10
    return $children;
11
}

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:

1
function list_taxonomy_hierarchy_no_instance( $taxonomies) {
2
?>
3
    <?php foreach ( $taxonomies as $taxonomy ) { ?>
4
        <?php $children = $taxonomy->children; ?>
5
        <option value="<?php echo $taxonomy->term_id; ?>"><?php echo $taxonomy->name; ?></option>
6
        <?php if (is_array($children) && !empty($children)): ?>
7
            <optgroup>
8
                <?php list_taxonomy_hierarchy_no_instance($children); ?>
9
            </optgroup>
10
        <?php endif ?>
11
    <?php } ?>
12
13
<?php
14
}

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:

For now, here is the product category transient:

1
function get_product_categories_hierarchy() {
2
3
    if ( false === ( $categories = get_transient( 'product-categories-hierarchy' ) ) ) {
4
5
        $categories = get_taxonomy_hierarchy( 'product_cat', 0, 0);
6
7
        // do not set an empty transient - should help catch private or empty accounts.

8
        if ( ! empty( $categories ) ) {
9
            $categories = base64_encode( serialize( $categories ) );
10
            set_transient( 'product-categories-hierarchy', $categories, apply_filters( 'null_categories_cache_time', 0 ) );
11
        }
12
    }
13
14
    if ( ! empty( $categories ) ) {
15
16
        return unserialize( base64_decode( $categories ) );
17
18
    } else {
19
20
        return new WP_Error( 'no_categories', esc_html__( 'No categories.', 'textdomain' ) );
21
22
    }
23
}

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.

1
function edit_product_term($term_id, $tt_id, $taxonomy) {
2
    $term = get_term($term_id,$taxonomy);
3
    if (!is_wp_error($term) && is_object($term)) {
4
        $taxonomy = $term->taxonomy;
5
        if ($taxonomy == "product_cat") {
6
            delete_transient( 'product-categories-hierarchy' );
7
        }
8
    }
9
}
10
11
function delete_product_term($term_id, $tt_id, $taxonomy, $deleted_term) {
12
    if (!is_wp_error($deleted_term) && is_object($deleted_term)) {
13
        $taxonomy = $deleted_term->taxonomy;
14
        if ($taxonomy == "product_cat") {
15
            delete_transient( 'product-categories-hierarchy' );
16
        }
17
    }
18
}
19
add_action( 'create_term', 'edit_product_term', 99, 3 );
20
add_action( 'edit_term', 'edit_product_term', 99, 3 );
21
add_action( 'delete_term', 'delete_product_term', 99, 4 );
22
23
add_action( 'save_post', 'save_post_action', 99, 3);
24
function save_post_action( $post_id ){
25
26
    if( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
27
    if (!current_user_can( 'edit_page', $post_id ) ) return;
28
29
    $post_info = get_post($post_id);
30
31
    if (!is_wp_error($post_info) && is_object($post_info)) {
32
        $content   = $post_info->post_content;
33
        $post_type = $post_info->post_type;
34
35
        if ($post_type == "product"){
36
            delete_transient( 'enovathemes-product-categories' );
37
        }
38
    }
39
40
}

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:

1
add_action('widgets_init', 'register_product_search_widget');
2
function register_product_search_widget(){
3
    register_widget( 'Enovathemes_Addons_WP_Product_Search' );
4
}
5
6
class Enovathemes_Addons_WP_Product_Search extends WP_Widget {
7
8
	public function __construct() {
9
		parent::__construct(
10
			'product_search_widget',
11
			esc_html__('* Product ajax search', 'textdomain'),
12
			array( 'description' => esc_html__('Product ajax search', 'textdomain'))
13
		);
14
	}
15
16
	public function widget( $args, $instance) {
17
18
		wp_enqueue_script('search-main');
19
20
		extract($args);
21
22
		$title = apply_filters( 'widget_title', $instance['title'] );
23
24
		echo $before_widget;
25
26
			if ( ! empty( $title ) ){echo $before_title . $title . $after_title;}
27
28
            ?>
29
30
			<div class="product-search">
31
				<form name="product-search" method="POST">
32
                    <?php $categories = get_product_categories_hierarchy(); ?>
33
                    <?php if ($categories): ?>
34
                        <select name="category" class="category">
35
                            <option class="default" value=""><?php echo esc_html__( 'Select a category', 'textdomain' ); ?></option>
36
                            <?php list_taxonomy_hierarchy_no_instance( $categories); ?>
37
                        </select>
38
                    <?php endif ?>
39
                    <div class="search-wrapper">
40
                        <input type="search" name="search" class="search" placeholder="<?php echo esc_html__( 'Search for product...', 'textdomain' ); ?>" value="">
41
                        <?php echo file_get_contents(plugins_url( 'images/loading.svg', __FILE__ )); ?>
42
                    </div>
43
	            </form>
44
                <div class="search-results"></div>
45
    		</div>
46
47
		<?php echo $after_widget;
48
	}
49
50
 	public function form( $instance ) {
51
52
 		$defaults = array(
53
 			'title' => esc_html__('Product search', 'textdomain'),
54
 		);
55
56
 		$instance = wp_parse_args((array) $instance, $defaults);
57
58
		?>
59
60
		<div id="<?php echo esc_attr($this->get_field_id( 'widget_id' )); ?>">
61
62
			<p>
63
				<label for="<?php echo $this->get_field_id( 'title' ); ?>"><?php echo esc_html__( 'Title:', 'textdomain' ); ?></label>
64
				<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'] ); ?>" />
65
			</p>
66
67
		</div>
68
69
		<?php
70
	}
71
72
	public function update( $new_instance, $old_instance ) {
73
		$instance = $old_instance;
74
		$instance['title'] = strip_tags( $new_instance['title'] );
75
		return $instance;
76
	}
77
78
}

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:

search widget search widget search widget

Looking awful! Let’s add some styles. 

6. Add Some Styles

Open the style.css file and add the following:

1
.product-search {
2
    position: relative;
3
    padding: 24px;
4
    border-radius: 4px;
5
    box-shadow:0px 0px 24px 0px rgba(0, 0, 0, 0.08);
6
    border:1px solid #e0e0e0;
7
    background: #f5f5f5;
8
}
9
10
.search-results {
11
    display: none;
12
    position: absolute;
13
    width: 200%;
14
    background: #ffffff;
15
    padding:12px 24px;
16
    border: 1px solid #e0e0e0;
17
    z-index: 15;
18
    transform: translateY(-1px);
19
}
20
21
.search-results.active {
22
    display: block;
23
}
24
25
.search-results ul {
26
    list-style: none;
27
    margin:0 !important;
28
    padding: 0 !important;
29
}
30
31
.search-results ul li {
32
    display: block;
33
    padding: 12px 0;
34
    position: relative;
35
    border-bottom: 1px dashed #e0e0e0;
36
}
37
38
.search-results ul li:last-child {
39
    border-bottom: none;
40
}
41
42
.search-results ul li a {
43
    display: table;
44
    width: 100%;
45
}
46
47
.search-results ul li a > * {
48
    display: table-cell;
49
    vertical-align: top;
50
}
51
52
.search-results .product-image {
53
    width: 72px;
54
    max-width: 72px;
55
}
56
57
.product-data {
58
    padding-left: 24px;
59
}
60
61
.search-results h3 {
62
    display: block;
63
}
64
65
.product-data div:not(.product-categories) {
66
    display: inline-block;
67
    vertical-align: middle;
68
}
69
70
.product-data .product-price {
71
    position: absolute;
72
    top: 12px;
73
    right: 0;
74
}
75
76
.product-data .product-stock {
77
    padding: 4px 8px;
78
    background: #eeeeee;
79
    border-radius: 4px;
80
    position: absolute;
81
    bottom: 12px;
82
    right: 0;
83
}
84
85
.product-categories > span {
86
    display: inline-block;
87
    margin-right: 4px;
88
}
89
90
.product-categories > span:after {
91
    content: ",";
92
}
93
94
.product-categories > span:last-child:after {
95
    content: "";
96
}
97
98
.product-categories > span:last-child {
99
    margin-right:0;
100
}
101
102
.product-search select {
103
    width: 100% !important;
104
    min-height: 40px !important;
105
    margin-bottom: 16px;
106
}
107
108
.product-search select,
109
.product-search input {
110
    background: #ffffff;
111
    border:1px solid #e0e0e0;
112
}
113
114
.search-wrapper {
115
    position: relative;
116
}
117
118
.search-wrapper input {
119
    padding-right: 35px !important;
120
}
121
122
.search-wrapper svg {
123
    position: absolute;
124
    top: 10px;
125
    right: 10px;
126
    width: 20px;
127
    height: 20px;
128
    fill:#bdbdbd;
129
    animation:loading 500ms 0ms infinite normal linear;
130
    transform-origin: center;
131
    opacity: 0;
132
}
133
134
.search-wrapper.loading svg {
135
    opacity:1;
136
}
137
138
@keyframes loading {
139
    from {transform: rotate(0deg);}
140
    to {transform: rotate(360deg);}
141
}

Now refresh the browser, (don’t forget about browser cache) and your widget should look much better:

product searchproduct searchproduct search

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:

1
(function($){
2
3
    "use strict";
4
$('form[name="product-search"]').each(function(){
5
6
        var form          = $(this),
7
            search        = form.find('.search'),
8
            category      = form.find('.category'),
9
            currentQuery  = '',
10
            timeout       = false;
11
12
        category.on('change',function(){
13
            currentQuery  = '';
14
            var query = search.val();
15
            productSearch(form,query,currentQuery,timeout);
16
        });
17
18
        search.keyup(function(){
19
            var query = $(this).val();
20
            productSearch(form,query,currentQuery,timeout);
21
        });
22
23
    });
24
25
})(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.

1
function productSearch(form,query,currentQuery,timeout){
2
3
    var search   = form.find('.search'),
4
        category = form.find('.category');
5
6
    form.next('.search-results').html('').removeClass('active');
7
8
    query = query.trim();
9
10
    if (query.length >= 3) {
11
12
        if (timeout) {
13
            clearTimeout(timeout);
14
        }
15
16
        form.next('.search-results').removeClass('empty');
17
18
        search.parent().addClass('loading');
19
        if (query != currentQuery) {
20
            timeout = setTimeout(function() {
21
22
                $.ajax({
23
                    url:opt.ajaxUrl,
24
                    type: 'post',
25
                    data: { action: 'search_product', keyword: query, category: category.val() },
26
                    success: function(data) {
27
                        currentQuery = query;
28
                        search.parent().removeClass('loading');
29
30
                        if (!form.next('.search-results').hasClass('empty')) {
31
32
                            if (data.length) {
33
                                form.next('.search-results').html('<ul>'+data+'</ul>').addClass('active');
34
                            } else {
35
                                form.next('.search-results').html(opt.noResults).addClass('active');
36
                            }
37
38
                        }
39
40
                        clearTimeout(timeout);
41
                        timeout = false;
42
43
44
                    }
45
                });
46
47
            }, 500);
48
        }
49
    } else {
50
51
        search.parent().removeClass('loading');
52
        form.next('.search-results').empty().removeClass('active').addClass('empty');
53
54
        clearTimeout(timeout);
55
        timeout = false;
56
57
    }
58
}

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:

1
function search_product() {
2
3
    global $wpdb, $woocommerce;
4
5
    if (isset($_POST['keyword']) && !empty($_POST['keyword'])) {
6
7
        $keyword = $_POST['keyword'];
8
9
        if (isset($_POST['category']) && !empty($_POST['category'])) {
10
11
            $category = $_POST['category'];
12
13
            $querystr = "SELECT DISTINCT * FROM $wpdb->posts AS p

14
            LEFT JOIN $wpdb->term_relationships AS r ON (p.ID = r.object_id)

15
            INNER JOIN $wpdb->term_taxonomy AS x ON (r.term_taxonomy_id = x.term_taxonomy_id)

16
        	INNER JOIN $wpdb->terms AS t ON (r.term_taxonomy_id = t.term_id)

17
        	WHERE p.post_type IN ('product')

18
        	AND p.post_status = 'publish'

19
            AND x.taxonomy = 'product_cat'

20
        	AND (

21
                (x.term_id = {$category})

22
                OR

23
                (x.parent = {$category})

24
            )

25
            AND (

26
                (p.ID IN (SELECT post_id FROM $wpdb->postmeta WHERE meta_key = '_sku' AND meta_value LIKE '%{$keyword}%'))

27
                OR

28
                (p.post_content LIKE '%{$keyword}%')

29
                OR

30
                (p.post_title LIKE '%{$keyword}%')

31
            )

32
        	ORDER BY t.name ASC, p.post_date DESC;";
33
34
        } else {
35
            $querystr = "SELECT DISTINCT $wpdb->posts.*

36
            FROM $wpdb->posts, $wpdb->postmeta

37
            WHERE $wpdb->posts.ID = $wpdb->postmeta.post_id

38
            AND (

39
                ($wpdb->postmeta.meta_key = '_sku' AND $wpdb->postmeta.meta_value LIKE '%{$keyword}%')

40
                OR

41
                ($wpdb->posts.post_content LIKE '%{$keyword}%')

42
                OR

43
                ($wpdb->posts.post_title LIKE '%{$keyword}%')

44
            )

45
            AND $wpdb->posts.post_status = 'publish'

46
            AND $wpdb->posts.post_type = 'product'

47
            ORDER BY $wpdb->posts.post_date DESC";
48
        }
49
50
        $query_results = $wpdb->get_results($querystr);
51
52
        if (!empty($query_results)) {
53
54
            $output = '';
55
56
            foreach ($query_results as $result) {
57
58
                $price      = get_post_meta($result->ID,'_regular_price');
59
                $price_sale = get_post_meta($result->ID,'_sale_price');
60
                $currency   = get_woocommerce_currency_symbol();
61
62
                $sku   = get_post_meta($result->ID,'_sku');
63
                $stock = get_post_meta($result->ID,'_stock_status');
64
65
                $categories = wp_get_post_terms($result->ID, 'product_cat');
66
67
                $output .= '<li>';
68
                    $output .= '<a href="'.get_post_permalink($result->ID).'">';
69
                        $output .= '<div class="product-image">';
70
                            $output .= '<img src="'.esc_url(get_the_post_thumbnail_url($result->ID,'thumbnail')).'">';
71
                        $output .= '</div>';
72
                        $output .= '<div class="product-data">';
73
                            $output .= '<h3>'.$result->post_title.'</h3>';
74
                            if (!empty($price)) {
75
                                $output .= '<div class="product-price">';
76
                                    $output .= '<span class="regular-price">'.$price[0].'</span>';
77
                                    if (!empty($price_sale)) {
78
                                        $output .= '<span class="sale-price">'.$price_sale[0].'</span>';
79
                                    }
80
                                    $output .= $currency;
81
                                $output .= '</div>';
82
                            }
83
                            if (!empty($categories)) {
84
                                $output .= '<div class="product-categories">';
85
                                    foreach ($categories as $category) {
86
                                        if ($category->parent) {
87
                                            $parent = get_term_by('id',$category->parent,'product_cat');
88
                                            $output .= '<span>'.$parent->name.'</span>';
89
                                        }
90
                                        $output .= '<span>'.$category->name.'</span>';
91
                                    }
92
                                $output .= '</div>';
93
                            }
94
                            if (!empty($sku)) {
95
                                $output .= '<div class="product-sku">'.esc_html__( 'SKU:', 'textdomain' ).' '.$sku[0].'</div>';
96
                            }
97
98
                            if (!empty($stock)) {
99
                                $output .= '<div class="product-stock">'.$stock[0].'</div>';
100
                            }
101
102
                        $output .= '</div>';
103
                        $output .= '</a>';
104
                $output .= '</li>';
105
            }
106
107
            if (!empty($output)) {
108
                echo $output;
109
            }
110
        }
111
    }
112
113
    die();
114
}
115
add_action( 'wp_ajax_search_product', 'search_product' );
116
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.

searching logo itemssearching logo itemssearching logo items

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!

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Web Design tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.