logo
Roman Kovalchik
Contact
Development

WooCommerce theme development basics using a commercial project as an example

Jun 12, 2022
17
  1. Introduction
  2. Optimization
  3. Products page
  4. Fragments refreshing
  5. Coupons (promo codes)
  6. Checkout
  7. Client is always right
  8. Conclusions

Introduction

WooCommerce is a plugin for CMS WordPress, the main task of which is the implementation of e-commerce. Even without programming skills, it allows you to create full-fledged online stores with wide functionality and the ability to edit content.

Speaking about web developers dealing with WooCommerce, even experienced ones usually face the following problems:

  1. Page loading speed optimization.
    WooCommerce uses a lot of third party CSS and JS assets that are loaded on all pages. Also, often even programmers don’t mind to install additional plugins in order to customize or expand the functionality of the store. In addition to styles and scripts, a large amount of PHP code inside installed plugins can also significantly slow down the rendering of the first content on the pages.
  2. Strict design adherence.
    WooCommerce uses default markup, styles, and scripts, be it a product page, a shopping cart page, a checkout page, and so on. Accordingly, the functionality of the server part of the plugin is directly related to the client part. Following the design layout in this case is quite difficult. But, looking ahead, I want to say that it is possible.
  3. Considering the two previous points – if the WooCommerce project is not developed rationally, it becomes slow, scales very quickly and, as a result, is hard to maintain. Long story short, it turns into a problem.

In this article, let’s take a look at my approach to writing a WooCommerce theme based on my commercial project. This is a multilingual online store for a restaurant with a fairly wide functionality and non-standard elements on the pages that are related to creating online orders.

The functionality of working with multiple languages will soon be described in a separate article.

The project itself, which is taken as an example, is much more than will be described now. Here we will cover the key points that will help you move in the right direction when developing a theme based on the WooCommerce plugin.

Optimization

Having a strict project design, I never use the default WooCommerce styles and scripts. So the first thing to do is disable them:

/* remove all WooCommerce default styles and scripts */
add_filter('woocommerce_enqueue_styles', '__return_false');

add_action('wp_enqueue_scripts', function () {
  wp_deregister_script('jquery'); 
  wp_dequeue_style('wc-blocks-style');
});

In my case, to disable all default scripts, it is enough to disable one script named jquery, on which all other plugin scripts depend. At the same time, I continue to work with the jQuery library inside theme and make sure that third-party plugins have the opportunity to work with this library if necessary.

You can read more about this and other WordPress project optimization techniques in my article WP-Starter. WordPress theme starter template.

If you decide not to disable the jquery script, but want to disable WooCommerce scripts, then you will have to disable each plugin script individually by specifying its name as an argument to the wp_dequeue_script or wp_deregister_script function.

Images

By default, when uploading each image to the WordPress media library, it is split into several images with different sizes. After installing WooCommerce, 6 more sizes are added, which are used in the plugin’s standard markup. When developing a custom theme, so much images with different sizes are unnecessary and lead to a fast filling of physical memory on the server. Therefore, it would be a good idea to disable the image sizes that WooCommerce provides:

<?php

add_action('init', function() {

  // remove Woocommerce default image sizes
  foreach(['woocommerce_thumbnail', 
  'woocommerce_single', 
  'woocommerce_gallery_thumbnail', 
  'shop_catalog', 
  'shop_single', 
  'shop_thumbnail'] as $image_name) remove_image_size($image_name);
});

Products page

Page link – https://wc-project.kovalchik.com.ua/en/

Fetching list of products is quite simple. There are several options, but personally I always use the well-known WP_Query class. Using this class and standard WooCommerce functions, you can get all the information about the product and display it on the client side of the site.

We will not linger on simple things and move on to more non-obvious ones.

Adding product to cart

To add a product to the cart, from the browser side, we need to send an ajax request to the server with either the id of the product itself or the key of the product that is already in the cart. On the server side, the function for adding a product looks like this:

<?php 

// add product to cart
add_action('wp_ajax_add_product_to_cart', 'add_product_to_cart');
add_action('wp_ajax_nopriv_add_product_to_cart', 'add_product_to_cart');
        
function add_product_to_cart() {

  $result = ['success' => false];
  $product_id = sanitize_text_field($_POST['product_id']);
  $cart_item_key_to_increase = sanitize_text_field($_POST['cart_item_key']);
  $add_quantity = 1;
  $cart = WC()->cart;

  // increase existing cart item quantity by cart item key
  if ($cart_item_key_to_increase) {
    foreach($cart->get_cart() as $cart_item_key => $cart_item) 
      if ($cart_item_key == $cart_item_key_to_increase) 
        if ($cart->set_quantity($cart_item_key, $cart_item['quantity'] + $add_quantity) ) {
          $result['success'] = true;
          $quantity = $cart_item['quantity'] + $add_quantity;
        }

  // add product to cart by product id
  } else if ($product_id) {
    add_free_items_to_cart($cart);

    $product_id = apply_filters('woocommerce_add_to_cart_product_id', absint($product_id) );
    $valid = apply_filters('woocommerce_add_to_cart_validation', true, $product_id, 1);

    if ($valid && 'publish' === get_post_status($product_id) )
      if ($cart->add_to_cart($product_id, $add_quantity) ) {
        do_action('woocommerce_ajax_added_to_cart', $product_id);
        $quantity = get_product_quantity_in_cart($product_id);
        $result['success'] = true;
      }
  }
  
  if ($result['success'] == true) {
    $result['quantity'] = $quantity;
    $result['fragments'] = apply_filters('woocommerce_add_to_cart_fragments', []);
  }

  echo wp_json_encode($result);
  wp_die();
}

It is worth noting that some of the code that I use in my functions is taken from the core code of the WooCommerce plugin itself, which is quite logical. For example, a default code has been added to this function that validates the product before adding it to the cart. That is, before trying to add any product, we must make sure that it is possible and not prohibited by the settings of the product in the admin panel.

Also, when writing your functionality for the store, you need to use the same WooCommerce hooks that are used in the plugin core, because these hooks can be used in other places in the project, for example, in third-party plugins.

When writing a WooCommerce project, we should try to stick to a developing style that keeps the theme compatible with third party plugins.

Removing product from cart

To remove an item from the cart, the same principle is used as for adding it. Only in this case, we need to either reduce the number of added products, or delete the entire position.

Reducing the number of items added to the cart:

<?php 

// remove product from cart (decrease quantity)
add_action('wp_ajax_remove_product_from_cart', 'remove_product_from_cart');
add_action('wp_ajax_nopriv_remove_product_from_cart', 'remove_product_from_cart');

function remove_product_from_cart() {

  $result = ['success' => false];
  $product_id = sanitize_text_field($_POST['product_id']);
  $cart_item_key_to_decrease = sanitize_text_field($_POST['cart_item_key']);
  $remove_quantity = 1;
  $cart = WC()->cart;

  if (!$cart_item_key_to_decrease)
    $cart_item_key_to_decrease = $cart->find_product_in_cart($cart->generate_cart_id($product_id) );

  // remove cart item by cart item key
  if ($cart_item_key_to_decrease) 
    foreach($cart->get_cart() as $cart_item_key => $cart_item) 
      if ($cart_item_key == $cart_item_key_to_decrease) {
        if ($cart_item['quantity'] > 0 && $remove_quantity <= $cart_item['quantity']) {
          $cart->set_quantity($cart_item_key, $cart_item['quantity'] - $remove_quantity);
          $quantity = $cart_item['quantity'] - $remove_quantity ;
          $result['success'] = true;
        }
      }

  // empty cart if only free items left
  if ($cart->total <= 0)
    $cart->empty_cart();

  if ($result['success'] == true) {
    $result['quantity'] = $quantity;
    $result['fragments'] = apply_filters('woocommerce_add_to_cart_fragments', []);
  }
 
  echo wp_json_encode($result);
  wp_die();
}

Removing an entire item position from the cart:

<?php  

// delete product position from cart
add_action('wp_ajax_delete_product_from_cart', 'delete_product_from_cart');
add_action('wp_ajax_nopriv_delete_product_from_cart', 'delete_product_from_cart');

function delete_product_from_cart() {
  
  $result = ['success' => false];
  $cart_item_key_to_delete = sanitize_text_field($_POST['cart_item_key']);
  $cart = WC()->cart;
  
  if ($cart->remove_cart_item($cart_item_key_to_delete) ) {

    // empty cart if only free items left
    if ($cart->total <= 0)
      $cart->empty_cart();

    $result = array(
      'success' => true,
      'quantity' => 0,
      'fragments' => apply_filters('woocommerce_add_to_cart_fragments', []),
    );
  }
  
  echo wp_json_encode($result); 
  wp_die();
}

Fragments refreshing

On the pages of the site, there must be elements whose content depends on the cart content. From the most obvious, this is the amount of the order in the cart and the total number of items added. Since we use ajax requests when working with the cart, we must at the same time update all the elements that depend on the cart and its content.

WooCommerce uses the woocommerce_add_to_cart_fragments filter hook by default for this purpose. If you haven’t noticed yet, I also use this hook in my code:

'fragments' => apply_filters('woocommerce_add_to_cart_fragments', [])

To update the number of products in the cart and the total amount of all products, the code for this hook in my project will look like this:

<?php 

add_filter('woocommerce_add_to_cart_fragments', function ($fragments) {

  $cart = WC()->cart;

  // mini cart items count
  $fragments['.mini-cart-count'] = $cart->get_cart_contents_count();

  // cart total price
  $fragments['.cart-price'] = get_cart_price();

  return $fragments; 
});

In this case, the markup of these elements in PHP files will look like this:

<div class="mini-cart-count">
  <?php echo WC()->cart->get_cart_contents_count() ?>
</div>

<div class="cart-price">
  <?php echo get_cart_price() ?>
</div>

That is, during the initial page load and when calculating fragments during an ajax request, the same functions work, but since the cart content has changed when a product is removed or added, different values will be returned. After counting new values, the $fragments variable is sent to the client side and all fragments are updated using the following JS function:

function refreshFragments(fragments) {
  $.each(fragments, function(key, value) {
    $(key).html(value)
  })
}

Coupons (promo codes)

Page link – https://wc-project.kovalchik.com.ua/en/cart/. Cart shouldn’t be empty , otherwise there will be a redirect to the home page.

Valid coupons:

zvnnccep – 10% discount
6avet5vc – 2 Euro discount

The wide functionality of WooCommerce coupons is implemented by default. Our task is to learn how to apply and remove them correctly.

To apply the coupon, we send its value with ajax request to the server. The server code for applying coupons looks like this:

<?php 

add_action('wp_ajax_apply_coupon', 'apply_coupon');
add_action('wp_ajax_nopriv_apply_coupon', 'apply_coupon');
        
function apply_coupon() {

  $result = ['success' => false, 'message' => 'Нельзя применить купон'];
  $code = sanitize_text_field($_POST['coupon_code']);
  $coupon = new WC_Coupon($code);
  $applied_coupons = WC()->cart->applied_coupons;

  if ($coupon->is_valid() && !$applied_coupons) {
    WC()->cart->add_discount($code);
    $result = array(
      'success' => true,
      'fragments' => apply_filters('woocommerce_add_to_cart_fragments', []),
      'message' => ''
    );
  }

  echo wp_json_encode($result);
  wp_die();
}

And here is the function to remove coupons:

<?php  

// remove coupon
add_action('wp_ajax_remove_coupon', 'remove_coupon');
add_action('wp_ajax_nopriv_remove_coupon', 'remove_coupon');

function remove_coupon() {

  $cart = WC()->cart;
  
  $result = ['success' => false];
  $code = sanitize_text_field($_POST['coupon_code']);
  $applied_coupons = $cart->applied_coupons;

  if (in_array($code, $applied_coupons) ) {
    $cart->remove_coupon($code);
    $result = array(
      'success' => true,
      'fragments' => apply_filters('woocommerce_add_to_cart_fragments', []),
    );
  }
  
  echo wp_json_encode($result);
  wp_die();
}

Checkout

Page link – https://wc-project.kovalchik.com.ua/en/checkout/. There must be products in the cart, otherwise there will be a redirect to the home page.

At first glance, customizing the checkout form looks like a very difficult task. In my project, the form design is made as a slider, with a fairly large number of non-standard fields, which further complicates the implementation, compatible with the default WooCommerce checkout functionality.

A few words about how the form is implemented on the client side.

In any project, I write forms using my tiny Form.js library. And as usual, the implementation fits in a few lines of code:

let form = $('.checkout-process-form')

// init checkout form
new Form({
  form: form,
  url: WPLocalize.admin_ajax,
  ajax_data: {
    action: 'checkout_process',
  },
  validators: {
    billing_first_name: /^[a-zа-я ,.'-]+$/i,
  },
  custom_ui: true,
})

// event after checkout form is sent. Redirect user to thankyou page if allowed
form.on('sent', (e, result) => {
  result = JSON.parse(result)
  if (result && result.message) alert(result.message)
  if (result && result.redirect) window.location.href = result.redirect
})

Form markup can be of any complexity, as long as it conforms to the basic semantic standards of HTML.

To place a WooCommerce order, we must pass the values of the fields with the following names to the server, so plugin itself is able to create new order: billing_first_name, billing_email, payment_method. Therefore, we put down these names in the markup of the corresponding fields on our own.

At this point, all form data is ready to be sent to the server. On the server, it remains to process them and create a new order.

By default, the WC_Checkout class inside the class-wc-checkout.php file is responsible for the back-end checkout functionality in the WooCommerce plugin. All we need to do is create our own class, extend it with the WC_Checkout class, and modify two of its methods slightly, the first one is responsible for receiving and validating the form data, and the second one, which sends the result back to the client side.

<?php 

class Checkout extends WC_Checkout  {
  
  public function __construct() {
    add_action('wp_ajax_nopriv_checkout_process', array($this, 'checkout_process') );
    add_action('wp_ajax_checkout_process', array($this, 'checkout_process') );
  }

  /* checkout process */
  function checkout_process() {

    $result = ['success' => false];

    wc_maybe_define_constant('WOOCOMMERCE_CHECKOUT', true);
    wc_set_time_limit(0);

    // check if cart isn't empty
    if (WC()->cart->is_empty() ) {
      $result['message'] = 'Извините, Ваша сессия истекла';
      echo wp_json_encode($result);
      wp_die();
    }

    // get checkout fields and convert array structure for native WC_Checkout methods processing
    $posted_data = [];
    foreach($_POST['fields'] as $field_name => $field)
      $posted_data[sanitize_text_field($field_name)] = sanitize_text_field($field['value']);

    // Update session for customer and totals.
    $this->update_session($posted_data);

    // validate user payment method
    if (WC()->cart->needs_payment() ) {
    $available_gateways = WC()->payment_gateways->get_available_payment_gateways();

    if (!isset($available_gateways[$posted_data['payment_method'] ]) ) {
      $result['message'] = 'Данный метод оплаты не поддерживается';
      echo wp_json_encode($result);
      wp_die();
      }
    }

    $this->process_customer($posted_data);
    $order_id = $this->create_order($posted_data);
    $order = wc_get_order($order_id);

    // catch order errors
    if (is_wp_error($order_id) ) {
      $result['message'] = $order_id->get_error_message();
      echo wp_json_encode($result);
      wp_die();
    }

    if (!$order) {
      $result['message'] = 'Ошибка создания заказа';
      echo wp_json_encode($result);
      wp_die();
    }

    // process payment and get redirect url (thankyou page)
    $result = array(
      'success' => true,
      'redirect' => $this->process_order_payment($order_id, $posted_data['payment_method'])
    );

    echo wp_json_encode($result);
    wp_die();
  }
  
  /* process order payment */
  protected function process_order_payment($order_id, $payment_method) {

    $available_gateways = WC()->payment_gateways->get_available_payment_gateways();

    // Store Order ID in session so it can be re-used after payment failure.
    WC()->session->set('order_awaiting_payment', $order_id);

    // Process Payment.
    $result = $available_gateways[$payment_method]->process_payment($order_id);

    // Redirect to success/confirmation/payment page.
    if (isset($result['result']) && 'success' === $result['result']) {
      $result['order_id'] = $order_id;
      $result = apply_filters('woocommerce_payment_successful_result', $result, $order_id);
      
      return $result['redirect'];
    }
  }
}

new Checkout();

Our new class sends a redirect link to the user after placing an order and works exactly the same as the default WC_Checkout class. At the same time, we just customized the receipt and validation of form data, tweaked the HTML markup a bit, and wrote the JS code for submitting data using ajax.

Since there are a lot of custom fields in our form, we need to display them ourselves inside the completed order. In my case, all fields are related to delivery information. Therefore, we set all user data in the Shipping section as a single string using the woocommerce_checkout_update_order_meta hook, inside which we have access to all form fields:

<?php

add_action('woocommerce_checkout_update_order_meta', function ($order_id, $data) {

  $shipping_string = '';

  if (isset($data['transportation_method']) && $data['transportation_method'] == 'pickup')
    $shipping_string = 'Pickup ' . '(' . $data['restaurant'] . ')';

  else {
    $keys = array(
      'restaurant' => 'Restaurant',
      'shipping_district' => 'District',
      'shipping_address' => 'Address',
      'shipping_floor' => 'Floor',
      'shipping_persons_count' => 'Number of persons',
      'shipping_date' => 'Desired date',
      'shipping_time' => 'Desired time',
      'shipping_delivery_type' => 'Delivery type'
    );
    
    foreach ($keys as $key => $label)
      if (isset($data[$key]) && $data[$key]) {
        if ($shipping_string) $shipping_string .= '; ';
        $shipping_string .=  $keys[$key] . ': ' . $data[$key];
      }
  }  

  update_post_meta($order_id, '_shipping_address_1', $shipping_string);
  
}, 10, 2);

The site administrator will see the following result:

This concludes the basics of my approach to writing a WooCommerce project.

But let’s look at the current project from the point of view of a couple of non-standard tasks from the client and my solutions.

Client is always right

Like WordPress itself in general, the Woocommerce plugin allows the developer to change the functionality using hooks. At almost any stage of the user’s interaction with the store, we have the opportunity to customize store behavior and edit the data that is processed and written to the database.

Let’s look at a couple of such hooks using real examples.

Task №1

It is necessary that when adding the first product to the cart, 3 other products are automatically added, the first unit of each of which must be free.

Referring to the function of adding an item to the cart, which we have already covered, it will not be difficult to automatically add other products. Let’s take a closer look at the implementation of the free first item of these products.

The woocommerce_before_calculate_totals hook fires before the cart total is calculated. It takes one argument – the cart object:

<?php  

add_action('woocommerce_before_calculate_totals', function ($cart) {
  if (is_admin() && !defined('DOING_AJAX') ) return;

  foreach ($cart->get_cart() as $cart_item) {
    $product = wc_get_product($cart_item['product_id']);

    if (get_field('product_one-for-free', $cart_item['product_id']) == 1) {
      $desired_total = $product->get_price() * $cart_item['quantity'] - $product->get_price();
      $new_product_price = $desired_total > 0 ? $desired_total / $cart_item['quantity'] : 0;
      $cart_item['data']->set_price($new_product_price);
    }
  }
    
}, 20, 1);

Inside the hook, I made a loop through all the items in the cart. Made sure that the current product in the loop is exactly the one whose first unit should be free. And with the help of simple mathematics, set a new price for the product in order to subtract the price of one unit from it in total.

With a price of 1 Euro for ginger and soy sauce, these items in the order will look like this:

At the same time, the price of these goods is still 1 euro. But for the duration of the cart session, we can programmatically do whatever we want with it. Well, almost everything.

Free items should not appear in the cart total.

We fix this with the cart_contents_count_woocommerce hook:

<?php 

add_filter('woocommerce_cart_contents_count', function($count) {

  foreach(WC()->cart->get_cart() as $cart_item_key => $cart_item) 
    if (get_field('product_one-for-free', $cart_item['product_id']) == 1) 
      if ($cart_item['quantity'] > 0)
        $count--;
    
  return $count;

}, 10, 1);

Task №2

In restaurants, it is possible to assemble a roll yourself, having a list of the proposed ingredients. It is necessary to implement such an opportunity on the site.

Page link – https://wc-project.kovalchik.com.ua/en/pick-roll/

To begin with, the “Pick your roll” product and all the necessary ingredients as separate products were created through the admin panel.

Our task is to send to the server the id of the main product and all the ids of the ingredients that the user has picked.

Using the woocommerce_add_cart_item_data hook, which is triggered before the product is added to the cart, we set the ids of all ingredients to the “Pick your roll” product data array:

<?php  

add_filter('woocommerce_add_cart_item_data', function($cart_item_data, $product_id, $variation_id) {

  if (get_field('product_one-for-free', $product_id) == 1)
    return;

  // set extra ingredients
  if (isset($_POST['extras']) && $_POST['extras'])
    $cart_item_data['custom_data']['extras'] = array_map('sanitize_text_field', $_POST['extras']);

  // set roll type
  if (isset($_POST['roll_type']) && $_POST['roll_type'])
    $cart_item_data['custom_data']['roll_type'] = sanitize_text_field($_POST['roll_type']);

  // set roll rice placement  
  if (isset($_POST['roll_rice_placement']) && $_POST['roll_rice_placement'])
    $cart_item_data['custom_data']['roll_rice_placement'] = sanitize_text_field($_POST['roll_rice_placement']);

  return $cart_item_data;
}, 20, 3);

Now you need to calculate and set the price for the roll collected by the user. We will use the already familiar woocommerce_before_calculate_totals hook:

<?php  

add_action('woocommerce_before_calculate_totals', function ($cart) {
  if (is_admin() && !defined('DOING_AJAX') ) return;

  foreach ($cart->get_cart() as $cart_item) {
    $product = wc_get_product($cart_item['product_id']);

    // extra costs for ingredients
    if (isset($cart_item['custom_data']['extras']) && $cart_item['custom_data']['extras']) {
      $extra_price = 0;
      
      foreach($cart_item['custom_data']['extras'] as $extra_product_id) {
        $ingredient = wc_get_product($extra_product_id);
        $extra_price += $ingredient->get_price();
      }
      
      $cart_item['data']->set_price($product->get_price() + $extra_price);
    }
  }
    
}, 20, 1);

And the last step is to show the administrator inside the placed order which ingredients were selected by the user. To do this, we use the woocommerce_checkout_create_order_line_item hook, where we can modify the product name:

<?php 

add_action('woocommerce_checkout_create_order_line_item', function($item, $cart_item_key, $values, $order) {

  // set product name in admin order products list
  if (isset($values['custom_data']) && $values['custom_data']) 
    if ($product_extras = get_cart_item_extras_string($values['custom_data']) ) // forms string with picked ingredients
      $item->set_props(['name' => $values['data']->name . ' ' . $product_extras]);
  
}, 10, 4);

This is how the order will look in the admin panel:

Conclusions

In this article, we have considered only a part of the functionality of one of my projects. The WooCommerce plugin itself provides the developer with great opportunities to implement a wide variety of e-commerce functionality. In addition to the plugin documentation, the open source code makes it possible to learn how to do it right.

That’s all. Share your experience in developing WooCommerce projects in the comments.

Be kind.

Jun 12, 2022
Like 3

Leave a Reply

Your email address will not be published.

images-wordpress Development
May 29, 2022
15
0

Dealing with images while writing a WordPress project

Best practices working with images writing a Wordpress theme.

bowl-with-food-682x1024 Nutrition
Aug 30, 2021
55
0

How to gain weight for an ectomorph? Even on a vegetarian diet

Long story short, an ectomorph is a skinny person with pretty thin bones, a minimal percentage of subcutaneous...

helpersScss Development
Sep 19, 2021
66
0

Helpers.scss. Library for comfortable CSS workflow

Helpers.scss library adheres to the basic principles of the well-known TailwindCSS library. But on the author's opinion...