Backend DevelopmentFrontend DevelopmentVueJs Tutorials

Building Ecommerce Website With Lumen Laravel And Nuxtjs 13: Prepare Shop Page

Building Ecommerce Website With PHP Lumen Laravel And Nuxtjs

The shop page is the most important page in any e-commerce store, so in this article we will implement and prepare the the shop page from Apis preparation until displaying it into the Nuxt UI. 

 

 

 

The shop page has many sections we need to implement as indicated by this figure:

 

  • Categories section: Display the categories in a tree view to filter the products by certain category.
  • Brands section: Display the brands based on the selected category to filter the brands by certain brand .
  • Products section: Display the products.
  • Price range section: To filter the products by price range.

 

Preparing Apis In Lumen

Let’s prepare the Apis in backend in the Lumen project. We will need just two apis, the first for the search purposes and the other for retrieving the brands list.

Go to the lumen project online-shop-backend/ in the Traits/ directory add SearchApi.php with this code:

app/Traits/SearchApi.php

<?php


namespace App\Traits;


use App\Models\Product;
use Illuminate\Support\Facades\DB;

trait SearchApi
{

    /**
     * getProductsForSearch
     *
     * Retrieves products for the search page like shop and category
     * accepts $params array that contain multiple fields to search with
     * like category_id, brand_id, etc
     *
     * @param array $params
     */
    public function getProductsForSearch($params = array())
    {
        extract($params);

        $query = Product::with('gallery', 'category', 'brand')
            ->select('id', 'title', 'description', 'price', 'discount', 'discount_start_date', 'discount_end_date', 'category_id', 'brand_id', 'featured')
            ->where('amount', '>', 0)
            ->has('gallery');

        if(isset($category_id) && !empty($category_id)) {
            $query->where('category_id', $category_id);
        }

        if(isset($brand_id) && !empty($brand_id)) {
            $query->where('brand_id', $brand_id);
        }

        if(isset($from_price) && !empty($from_price) && is_numeric($from_price)) {
            $query->where('price', '>=', floatval($from_price));
        }

        if(isset($to_price) && !empty($to_price) && is_numeric($to_price)) {
            $query->where('price', '<=', floatval($to_price));
        }

        if(isset($keyword) && !empty($keyword)) {
            $query->where('title', 'like', "%$keyword%");
        }

        if(isset($except)) {
            $query->where('id', '!=', $except);
        }

        $products = $query->orderBy('id', 'DESC')
                ->paginate(12);

        return response()->json(['products' => $products]);
    }


    /**
     * getBrandsByCategory
     *
     * get brands and their product counts by category
     *
     * @param $categoryId
     */
    public function getBrandsByCategory($categoryId)
    {
        $brands = DB::table('products AS p')
                        ->join('brands', 'brands.id', '=', 'p.brand_id')
                        ->where('amount', '>', 0)
                        ->where('category_id', $categoryId)
                        ->whereRaw('exists (select * from `product_gallery` where p.id = product_gallery.product_id)')
                        ->select(DB::raw('DISTINCT brand_id, brands.title, (select count(*) from products where products.brand_id = p.brand_id and products.category_id = p.category_id) as count_products'))
                        ->get();


        return response()->json(['brands' => $brands]);
    }
}

In the above code getProductsForSearch() method search products using various criteria, this enable us to retrieve products using category, brand, price, or keyword. The getBrandsByCategory() method retrieves brands using specific category, the method also queries the number of products in each brand.

 

Updating Controllers & Routes

Update the app/Http/Controllers/ProductsController.php add this method and the end of the controller:

......
use App\Traits\SearchApi;

class ProductsController extends Controller
{
    use Helpers, HomeApi, SearchApi;

    ......
    ......

    public function searchProducts(Request $request)
    {
        return $this->getProductsForSearch($request->toArray());
    }

}

Also update app/Http/Controllers/BrandsController.php add this method:

use App\Traits\SearchApi;
...
...

class BrandsController extends Controller
{
    use SearchApi;

    ....
    ....

    public function brandsByCategory(Request $request)
    {
        return $this->getBrandsByCategory($request->category_id);
    }

}

Update routes/web.php

<?php

/*
|--------------------------------------------------------------------------
| Application Routes
|--------------------------------------------------------------------------
|
| Here is where you can register all of the routes for an application.
| It is a breeze. Simply tell Lumen the URIs it should respond to
| and give it the Closure to call when that URI is requested.
|
*/

$router->get('/', function () use ($router) {
    return $router->app->version();
});

$router->group(['prefix' => 'api'], function () use ($router) {
    $router->post('/login', 'Auth\\LoginController@login');
    $router->post('/register', 'Auth\\RegisterController@register');

    $router->group(['prefix' => 'category'], function () use ($router) {
        $router->get('/', 'CategoriesController@index');
        $router->get('/htmltree', 'CategoriesController@getCategoryHtmlTree');
        $router->get('/menutree', 'CategoriesController@getCategoryMenuHtmlTree');
        $router->get('/featured-categories', 'CategoriesController@featuredCategories');
        $router->get('/{id}', 'CategoriesController@show');
    });

    $router->group(['prefix' => 'brand'], function () use ($router) {
        $router->get('/', 'BrandsController@index');
        $router->get('/brands-by-category', 'BrandsController@brandsByCategory');
        $router->get('/{id}', 'BrandsController@show');
    });

    $router->group(['prefix' => 'product'], function () use ($router) {
        $router->get('/', 'ProductsController@index');
        $router->get('/slider-products', 'ProductsController@sliderProducts');
        $router->get('/latest-products', 'ProductsController@latestProducts');
        $router->get('/featured-products', 'ProductsController@featuredProducts');
        $router->get('/search-products', 'ProductsController@searchProducts');
        $router->get('/{id}', 'ProductsController@show');
    });

    $router->group(['prefix' => 'user'], function () use ($router) {
        $router->get('/', 'UsersController@index');
        $router->get('/{id}', 'UsersController@show');
    });

    $router->group(['middleware' => 'auth:api'], function () use ($router) {
        $router->get('/me', 'Auth\\LoginController@userDetails');
        $router->get('/logout', 'Auth\\LoginController@logout');
        $router->get('/check-login', 'Auth\\LoginController@checkLogin');

        $router->group(['prefix' => 'category'], function () use ($router) {
            $router->post('/', 'CategoriesController@store');
            $router->put('/{id}', 'CategoriesController@update');
            $router->delete('/{id}', 'CategoriesController@destroy');
        });

        $router->group(['prefix' => 'brand'], function () use ($router) {
            $router->post('/', 'BrandsController@store');
            $router->put('/{id}', 'BrandsController@update');
            $router->delete('/{id}', 'BrandsController@destroy');
        });

        $router->group(['prefix' => 'product'], function () use ($router) {
           $router->post('/', 'ProductsController@store');
            $router->put('/{id}', 'ProductsController@update');
            $router->delete('/delete-image/{id}', 'ProductsController@destroyImage');
            $router->delete('/{id}', 'ProductsController@destroy');
        });

        $router->group(['prefix' => 'user'], function () use ($router) {
            $router->post('/', 'UsersController@store');
            $router->put('/{id}', 'UsersController@update');
            $router->delete('/{id}', 'UsersController@destroy');
        });
    });
});

 

Updating Nuxtjs Pages

In the main Nuxt project online-shop-frontend/ in the api/ directory create two files:

  • category.js
  • shop.js

api/shop.js

const ShopApi = {
  search: (axios, search_params = '') => {
    return axios.$get('/api/product/search-products' + (search_params ? "?" + search_params : ""));
  },
  getBrandsByCategory: (axios, categoryId) => {
    return axios.$get('/api/brand/brands-by-category?category_id=' + categoryId);
  }
}

export {ShopApi};

api/category.js

const CategoryApi = {
  getById: (axios, categoryId) => {
    return axios.$get('/api/category/' + categoryId);
  }
}

export {CategoryApi};

 

Shop Page & Components

The next step is to update pages/shop.vue

<template>
    <section>
      <div class="container">
        <div class="row">
          <div class="col-sm-3">
            <ShopSidebar pageType="shop" :categoriesTree="categoriesTree"></ShopSidebar>
          </div>

          <div class="col-sm-9 padding-right" v-if="this.products.data && this.products.data.length">
            <div class="features_items">
              <h2 class="title text-center">Latest Items</h2>

              <div class="col-sm-4" v-for="(item, index) in this.products.data" :key="index">
                <ProductTemplateNormal :item="item"></ProductTemplateNormal>
              </div>

              <FrontPagination :data="this.products" v-on:handlePagination="paginate"></FrontPagination>
            </div>
          </div>
          <div class="col-sm-9 padding-right" v-else>
            <p class="text-center no-products"><i class="fa fa-exclamation-triangle"></i> No products found that match your search criteria!</p>
          </div>
        </div>
      </div>
    </section>
</template>

<script>
    import ShopSidebar from "../components/shop-components/ShopSidebar";
    import ProductTemplateNormal from "../components/product-templates/ProductTemplateNormal";
    import FrontPagination from "../components/helpers/FrontPagination";

    export default {
      name: "Shop",
      components: {
        FrontPagination,
        ProductTemplateNormal,
        ShopSidebar
      },
      computed: {
        categoriesTree() {
          return [];
        },
        products() {
          return [];
        },
        page() {
          return 1;
        }
      },
      head() {
        return {
          title: 'Online Shop | Shop',
          meta: [
            {
              hid: 'description',
              name: 'description',
              content: 'Shop Page'
            }
          ]
        }
      },
      methods: {
        paginate(page_number) {
          // todo
        }
      },
      mounted() {
        // load products on first mount
      }
    }
</script>

<style scoped>
   .col-sm-4 {
     height: 437px !important;
     margin-bottom: 30px !important;
   }
  .no-products {
    color: #696763;
    font-size: 15px;
    font-family: 'Roboto', sans-serif;
  }
</style>

The code above contains two new components which is <ShopSidebar /> and <FrontPagination />. <ShopSidebar /> contains the category tree, brands list and the price range controls while the <FrontPagination /> component displays pagination links. Let’s create these components as shown below.

components/helpers/FrontPagination.vue

<template>
  <ul class="pagination" v-if="showPaginator()">
    <li v-if="data.current_page > 1">
      <a href="#" @click.prevent="displayPage(data.current_page - 1)">&laquo;</a>
    </li>

    <li v-for="page in numPages()" :key="page" :class="(data.current_page == page?' active':'')" >
      <a href="#" @click.prevent="displayPage(page)">{{ page }}</a>
    </li>

    <li v-if="data.current_page < data.last_page">
      <a href="#" @click.prevent="displayPage(data.current_page + 1)">
        &raquo;
      </a>
    </li>
  </ul>
</template>

<script>
    export default {
        name: "FrontPagination",
      props: ["data"],
      methods: {
        numPages() {
          return Math.ceil(this.data.total / this.data.per_page);
        },
        showPaginator() {
          if(this.numPages() > 1) {
            return true;
          }

          return false;
        },
        displayPage(page_number) {
          this.$emit("handlePagination", page_number);
        }
      }
    }
</script>

<style scoped>
  .pagination {
    margin-top: 50px !important;
  }
</style>

components/shop-components/ShopSidebar.vue

<template>
  <div class="left-sidebar">

    <h2 v-if="pageType === 'shop'">Category</h2>
    <ShopCategoryTree :categoriesTree="categoriesTree" v-if="pageType === 'shop'"></ShopCategoryTree>

    <div class="brands_products" v-if="brands.length && (pageType === 'shop' || pageType === 'category')"><!--brands_products-->
      <h2>Brands</h2>
      <div class="brands-name">
        <ul class="nav nav-pills nav-stacked">
          <li>
            <a href="#">
              <span class="pull-right">(5)</span>BRAND NAME</a>
          </li>
         <li>
            <a href="#">
              <span class="pull-right">(5)</span>BRAND NAME</a>
          </li>
          <li>
            <a href="#">
              <span class="pull-right">(5)</span>BRAND NAME</a>
          </li>
          <li>
            <a href="#">
              <span class="pull-right">(5)</span>BRAND NAME</a>
          </li>
        </ul>
      </div>
    </div><!--/brands_products-->

    <div class="price-range"><!--price-range-->
      <h2>Price Range</h2>
      <div class="well">

        <input type="number" name="from_price" placeholder="From $" class="form-control" style="margin-bottom: 5px"/>
        <input type="number" name="to_price" placeholder="To $" class="form-control"/>

      </div>
    </div><!--/price-range-->

  </div>
</template>

<script>
    import ShopCategoryTree from "./tree/ShopCategoryTree";
    export default {
        name: "ShopSidebar",
        components: {ShopCategoryTree},
        props: ["categoriesTree", "pageType"],
        computed: {
         
        },
        methods: {
          
        }
    }
</script>

<style scoped>
  .price-range .well {
    padding-top: 10px !important;
  }
</style>

Also create a new directory tree/ this directory contains category tree components inside of components/shop-components then create a new component inside ShopCategoryTree.

components/shop-components/tree/ShopCategoryTree.vue

<template>

      <div class="panel-group category-products" id="accordion">

        <!-- display categories here -->

      </div>
</template>

<script>
    export default {
        name: "ShopCategoryTree",
        props: ["categoriesTree"],
        mounted() {
          
        },
        methods: {
          
        }
    }
</script>

<style scoped>
  .leaf-node {
    font-weight: bold;
    font-size: 11px;
  }
</style>

Now let’s start by displaying dynamic data into the shop page, to achieve this we will work on the <ShopSidebar /> component at first, as you see in the above figure there are category tree in the sidebar, so we need to display this tree.

You may remember that we displayed the category tree in the website menu using recursive components, we will use the same technique to display this tree again but in this case in the shop sidebar, also i will create a store to do this so that we can use that store in both the menu and shop sidebar.

 

Vuex Store

Let’s create a store in the store/ directory named general.js, i will use this store for the entire website. Add this code as shown below:

store/general.js

import { HomeApis } from '../api/home';
import {ShopApi} from "../api/shop";
import {CategoryApi} from "../api/category";

export const state = () => ({
  categoriesTree: [],
  shop: {
    products: [],
    page: 1,
    brandsByCategory: []
  },
  shop_filter: {
    categoryId: '',
    brand_id: '',
    from_price: '',
    to_price: '',
    keyword: ''
  },
  category: {}
});

export const mutations = {
  setCategoryTree(state, data) {
    state.categoriesTree = data;
  },
  setProducts(state, data) {
    state.shop.products = data;
  },
  setPage(state, page) {
    state.shop.page = page;
  },
  setBrandsByCategory(state, brands) {
    state.shop.brandsByCategory = brands;
  },
  setCategoryId(state, categoryId) {
    state.shop_filter.categoryId = categoryId;
  },
  setBrand(state, brandId) {
    state.shop_filter.brand_id = brandId;
  },
  setFromPrice(state, price) {
    state.shop_filter.from_price = price;
  },
  setToPrice(state, price) {
    state.shop_filter.to_price = price;
  },
  setKeyword(state, keyword) {
    state.shop_filter.keyword = keyword;
  },
  setCategory(state, category) {
    state.category = category;
  }
};

export const actions = {
  fetchCategoryTree({commit}) {
    HomeApis.getCategoryMenuTree(this.$axios).then(res => {
      commit('setCategoryTree', res);
    });
  },
  async fetchShopProducts({commit, state}) {

    let searchParams = [];

    if(state.shop.page && parseInt(state.shop.page) >= 1 ) {
      searchParams.push("page=" + parseInt(state.shop.page));
    }

    if(state.shop_filter.categoryId && parseInt(state.shop_filter.categoryId) > 0) {
      searchParams.push("category_id=" + parseInt(state.shop_filter.categoryId));
    }

    if(state.shop_filter.brand_id && parseInt(state.shop_filter.brand_id) > 0) {
      searchParams.push("brand_id=" + parseInt(state.shop_filter.brand_id));
    }

    if(state.shop_filter.from_price) {
      searchParams.push("from_price=" + state.shop_filter.from_price);
    }

    if(state.shop_filter.to_price) {
      searchParams.push("to_price=" + state.shop_filter.to_price);
    }

    if(state.shop_filter.keyword) {
      searchParams.push("keyword=" + state.shop_filter.keyword);
    }

    const response = await ShopApi.search(this.$axios, searchParams.join("&"));
    commit('setProducts', response.products);
  },
  async fetchBrandsByCategory({commit}, categoryId) {
    const response = await ShopApi.getBrandsByCategory(this.$axios, categoryId);

    commit('setBrandsByCategory', response.brands);
  },
  async fetchCategory({commit}, categoryId) {
    const response = await CategoryApi.getById(this.$axios, categoryId);

    commit('setCategory', response.category);

    return new Promise( (resolve) => {
      resolve(response.category);
    });
  },
  resetShopFilter({commit}) {
    commit('setPage', 1);
    commit('setCategoryId', "");
    commit('setBrand', "");
    commit('setFromPrice', "");
    commit('setToPrice', "");
    commit('setKeyword', "");
    commit('setBrandsByCategory', []);
  }
}

The above code represent the store we will use in the shop, search and category pages. The store state contains these keys for now:

  • categoriesTree: An array of tree of categories.
  • shop: An object that represent the shop data like products, brands, and page(pagination).
  • shop_filter: An object that represent the shop filter criteria like category_id, brand_id, pricing.
  • category: An object that represent specific category details.

The mutations is so simple so i won’t go to explain them. For the actions we have fetchCategoryTree(), fetchShopProducts() triggered when searching in the shop, category and category pages. The fetchBrandsByCategory() action responsible for retrieving brands. fetchCategory() responsible for retrieving category details, and finally resetShopFilter() action to reset the filters.

Now let’s return back to the shop page and update it as follows:

pages/shop.vue

<template>
    <section>
      <div class="container">
        <div class="row">
          <div class="col-sm-3">
            <ShopSidebar pageType="shop" :categoriesTree="categoriesTree"></ShopSidebar>
          </div>

          <div class="col-sm-9 padding-right" v-if="this.products.data && this.products.data.length">
            <div class="features_items">
              <h2 class="title text-center">Latest Items</h2>

              <div class="col-sm-4" v-for="(item, index) in this.products.data" :key="index">
                <ProductTemplateNormal :item="item"></ProductTemplateNormal>
              </div>

              <FrontPagination :data="this.products" v-on:handlePagination="paginate"></FrontPagination>
            </div>
          </div>
          <div class="col-sm-9 padding-right" v-else>
            <p class="text-center no-products"><i class="fa fa-exclamation-triangle"></i> No products found that match your search criteria!</p>
          </div>
        </div>
      </div>
    </section>
</template>

<script>
    import ShopSidebar from "../components/shop-components/ShopSidebar";
    import ProductTemplateNormal from "../components/product-templates/ProductTemplateNormal";
    import FrontPagination from "../components/helpers/FrontPagination";
    import {paginate} from "../helpers/functions";

    export default {
      name: "Shop",
      components: {
        FrontPagination,
        ProductTemplateNormal,
        ShopSidebar
      },
      computed: {
        categoriesTree() {
          return this.$store.state.general.categoriesTree;
        },
        products() {
          return this.$store.state.general.shop.products;
        },
        page() {
          return this.$store.state.general.shop.page;
        }
      },
      head() {
        return {
          title: 'Online Shop | Shop',
          meta: [
            {
              hid: 'description',
              name: 'description',
              content: 'Shop Page'
            }
          ]
        }
      },
      methods: {
        paginate(page_number) {
            paginate(this, page_number);
        }
      },
      mounted() {
        // reset shop filter
        this.$store.dispatch('general/resetShopFilter');


        if(this.$route.query.page) {
          this.$store.commit('general/setPage', this.$route.query.page);
        }

        if(this.$route.query.category_id) {
          this.$store.commit('general/setCategoryId', this.$route.query.category_id);

          // load brands by this category
          this.$store.dispatch('general/fetchBrandsByCategory', this.$route.query.category_id);
        }

        if(this.$route.query.brand_id) {
          this.$store.commit('general/setBrand', this.$route.query.brand_id);
        }

        if(this.$route.query.from_price) {
          this.$store.commit('general/setFromPrice', this.$route.query.from_price);
        }

        if(this.$route.query.to_price) {
          this.$store.commit('general/setToPrice', this.$route.query.to_price);
        }

        this.$nextTick(() => {
          this.$nuxt.$loading.start();

          this.$store.dispatch('general/fetchShopProducts').then(() => {
            this.$nuxt.$loading.finish();
          });
        });
      }
    }
</script>

<style scoped>
   .col-sm-4 {
     height: 437px !important;
     margin-bottom: 30px !important;
   }
  .no-products {
    color: #696763;
    font-size: 15px;
    font-family: 'Roboto', sans-serif;
  }
</style>

In the code above i make some updates to read dynamic data from the store, such as categoriesTree, products, page. Then i passed those data as props to the partial components. Also i added another file functions.js inside of a new created directory helpers/ which will contain helper functions such as paginate().

Finally in mounted hook i make a call to retrieve the products by dispatching store action “general/fetchShopProducts” but before we do this i make some checks for the presence of query strings like category_id, brand_id, page, etc. so that those query string sent along with the request that fetches the products.

 

Now update components/shop-components/ShopSidebar.vue

<template>
  <div class="left-sidebar">

    <h2 v-if="pageType === 'shop'">Category</h2>
    <ShopCategoryTree :categoriesTree="categoriesTree" v-if="pageType === 'shop'"></ShopCategoryTree>

    <div class="brands_products" v-if="brands.length && (pageType === 'shop' || pageType === 'category')"><!--brands_products-->
      <h2>Brands</h2>
      <div class="brands-name">
        <ul class="nav nav-pills nav-stacked">
          <li v-for="brand in this.brands" :key="brand.brand_id">
            <a href="#" @click.prevent="searchByBrand(brand.brand_id)">
              <span class="pull-right">({{ brand.count_products }})</span>{{ brand.title }}</a>
          </li>
        </ul>
      </div>
    </div><!--/brands_products-->

    <div class="price-range"><!--price-range-->
      <h2>Price Range</h2>
      <div class="well">

        <input type="number" name="from_price" placeholder="From $" class="form-control" style="margin-bottom: 5px" @change="updateFromPrice" :value="this.$store.state.general.shop_filter.from_price" />
        <input type="number" name="to_price" placeholder="To $" class="form-control" @change="updateToPrice" :value="this.$store.state.general.shop_filter.to_price" />

      </div>
    </div><!--/price-range-->

  </div>
</template>

<script>
    import ShopCategoryTree from "./tree/ShopCategoryTree";
    import {updateRouteQueryString} from "../../helpers/functions";

    export default {
        name: "ShopSidebar",
        components: {ShopCategoryTree},
        props: ["categoriesTree", "pageType"],
        computed: {
          brands() {
            return this.$store.state.general.shop.brandsByCategory;
          }
        },
        methods: {
          searchByBrand(brand_id) {
            this.$store.commit('general/setPage', 1);
            this.$store.commit('general/setBrand', brand_id);
            this.$store.commit('general/setKeyword', '');
            this.$store.commit('general/setFromPrice', '');
            this.$store.commit('general/setToPrice', '');

            if(this.$route.path.indexOf('shop') !== -1) {
              this.$router.push({path: 'shop', query: {category_id: this.$route.query.category_id, brand_id}});
            } else {
              this.$router.push({path: this.$route.path, query: {brand_id}});
            }

            this.$store.dispatch('general/fetchShopProducts');
          },
          updateFromPrice(event) {
            this.$store.commit('general/setFromPrice', event.target.value);

            // update url
            updateRouteQueryString('from_price', event.target.value, this.$route, this.$router);

            // fetch
            this.$store.dispatch('general/fetchShopProducts');
          },
          updateToPrice(event) {
            this.$store.commit('general/setToPrice', event.target.value);

            // update url
            updateRouteQueryString('to_price', event.target.value, this.$route, this.$router);

            // fetch
            this.$store.dispatch('general/fetchShopProducts');
          }
        }
    }
</script>

<style scoped>
  .price-range .well {
    padding-top: 10px !important;
  }
</style>

The <ShopSidebar /> component will be shown in three pages, which are the shop the current page we work on, the search page and the category page. For this purpose i will show the category tree only in the shop page, this is why i check for pageType as shown above:

<h2 v-if="pageType === 'shop'">Category</h2>
    <ShopCategoryTree :categoriesTree="categoriesTree" v-if="pageType === 'shop'"></ShopCategoryTree>

Next we displayed the brands coming from the store, the brands retrieved whenever we select certain category or when we reload the page, each brand have an id, title, and count_products. and i added another method searchByBrand() which triggers the “general/fetchShopProducts” store action to search products by sending the brand_id.

In the same way the price range filter works on the onchange event of each input for example the from price triggers updateFromPrice() which by turn updates the store, then update the url and finally trigger the search.

 

The remaining components is category tree related components which are <ShopCategoryTree /> and <ShopCategoryTreeNested /> let’s update them:

components/shop-components/tree/ShopCategoryTree.vue

<template>

      <div class="panel-group category-products" id="accordion">

        <div class="panel panel-default" v-for="item in this.categoriesTree" :key="'shop-category-' + item.id">

           <div class="panel-heading">
              <h4 class="panel-title">
                <a href="#" class="leaf-node" v-if="item.children.length === 0" @click.prevent="searchByCategory(item.id)">{{ item.title }}</a>

                <a v-if="item.children.length > 0" data-toggle="collapse" :href="'#category-' + item.id">
                  <span class="badge pull-right"><i class="fa fa-plus"></i></span>
                  {{ item.title }}
                </a>

              </h4>
            </div>

            <div v-if="item.children.length > 0" :id="'category-' + item.id" class="panel-collapse collapse">
              <div class="panel-body">
                <ShopCategoryTreeNested :categoriesTree="item.children" :parent-id="item.id" :collapseFirstLevel="true" @clickCategory="searchByCategory"></ShopCategoryTreeNested>
              </div>
            </div>
        </div>

      </div>
</template>

<script>
    import ShopCategoryTreeNested from "./ShopCategoryTreeNested";

    export default {
        name: "ShopCategoryTree",
        components: {ShopCategoryTreeNested},
        props: ["categoriesTree"],
        mounted() {
          setTimeout(() => {
            if($("#accordion").length) {
              $("#accordion").collapse();
            }
          }, 200);
        },
        methods: {
          searchByCategory(categoryId) {
            this.$store.commit('general/setCategoryId', categoryId);
            this.$store.commit('general/setPage', 1);
            this.$store.commit('general/setBrand', '');
            this.$store.commit('general/setFromPrice', '');
            this.$store.commit('general/setToPrice', '');
            this.$store.commit('general/setKeyword', '');

            // load brands by this category
            this.$store.dispatch('general/fetchBrandsByCategory', categoryId);

            this.$router.push({ path: 'shop', query: { category_id: categoryId }});

            this.$store.dispatch('general/fetchShopProducts');
          }
        }
    }
</script>

<style scoped>
  .leaf-node {
    font-weight: bold;
    font-size: 11px;
  }
</style>

components/shop-components/tree/ShopCategoryTreeNested.vue

<template>
      <ul :id="'category-' + parentId" :class="collapseFirstLevel === true ? '' : 'collapse'">
        <li v-for="item in this.categoriesTree" :key="'shop-category-' + item.id">
          <a href="#" class="leaf-node" v-if="item.children.length === 0" @click.prevent="searchByCategory(item.id)">{{ item.title }}</a>
          <a data-toggle="collapse" :href="'#category-' + item.id" v-if="item.children.length > 0">
            <span class="badge"><i class="fa fa-plus"></i></span> {{ item.title }}
          </a>
          <ShopCategoryTreeNested v-if="item.children.length > 0" :categoriesTree="item.children" :parentId="item.id" @clickCategory="searchByCategory"></ShopCategoryTreeNested>
        </li>
      </ul>
</template>

<script>
    export default {
        name: "ShopCategoryTreeNested",
        props: ["categoriesTree", "parentId", "collapseFirstLevel"],
        data() {
          return {
            show_nested: false
          }
        },
        methods: {
          searchByCategory(itemId) {
            this.$emit('clickCategory', itemId);
          }
        }
    }
</script>

<style scoped>
  .leaf-node {
    font-weight: bold;
    font-size: 11px;
  }
</style>

This category tree works in the same way as the menu tree we built in previous parts.

Create a new directory helpers/ in the root project and create a new file functions.js. This file will contain helper functions:

helpers/functions.js

export const updateRouteQueryString = (key, value, $route, $router) => {
  let query = {...$route.query};

  query[key] = value;

  $router.push({ path: $route.path, query});
};

export const paginate = ({$store, $route, $router}, page_number) => {
  $store.commit('general/setPage', page_number);

  updateRouteQueryString('page', page_number, $route, $router);

  $store.dispatch('general/fetchShopProducts');
};

With this the shop page is now completed, you can try it with real data.

 

Updating The Header

At this point we have to update <FrontHeader /> component so that we can read the categoriesTree from the store

components/partials/FrontHeader.vue

<template>
  <header id="header"><!--header-->

    <div class="header-middle"><!--header-middle-->
      <div class="container">
        <div class="row">
          <div class="col-sm-4">
            <div class="logo pull-left">
              <nuxt-link to="/"><img src="/images/home/logo.png" alt="" /></nuxt-link>
            </div>
          </div>
          <div class="col-sm-8">
            <div class="shop-menu pull-right">
              <ul class="nav navbar-nav">
                <li><a href="#"><i class="fa fa-user"></i> Account</a></li>
                <li><a href="#"><i class="fa fa-star"></i> Wishlist</a></li>
                <li><a href="#"><i class="fa fa-list"></i> My Orders</a></li>
                <li><nuxt-link to="/cart"><i class="fa fa-shopping-cart"></i> Cart</nuxt-link></li>
                <li><nuxt-link to="/login"><i class="fa fa-lock"></i> Login</nuxt-link></li>
              </ul>
            </div>
          </div>
        </div>
      </div>
    </div><!--/header-middle-->

    <div class="header-bottom"><!--header-bottom-->
      <div class="container">
        <div class="row">
          <div class="col-sm-9">
            <div class="navbar-header">
              <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                <span class="sr-only">Toggle navigation</span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
              </button>
            </div>
            <div class="mainmenu pull-left">
              <ul class="nav navbar-nav collapse navbar-collapse">
                <li><nuxt-link to="/" :class="{active: this.$route.path === '/'}">Home</nuxt-link></li>
                <li><nuxt-link to="/shop" :class="{active: this.$route.path.indexOf('shop') !== -1}">Shop</nuxt-link></li>
                <li class="dropdown">
                  <a href="#">Categories<i class="fa fa-angle-down"></i></a>
                  <CategoryTree v-if="this.categoriesTree.length" :dataTree="categoriesTree"></CategoryTree>
                </li>
                <li><nuxt-link to="/contactus" :class="{active: this.$route.path.indexOf('contactus') !== -1}">Contact</nuxt-link></li>
              </ul>
            </div>
          </div>
          <div class="col-sm-3">
            <form method="get" @submit.prevent="search()">
              <div class="search_box pull-right">
                <input type="text" name="keyword" placeholder="Search" v-model="keyword" />
              </div>
            </form>
          </div>
        </div>
      </div>
    </div><!--/header-bottom-->
  </header>
</template>

<script>
    import CategoryTree from '../../components/partials/CategoryTree';
    export default {
        name: "FrontHeader",
        components: {CategoryTree},
        data() {
          return {
            keyword: ""
          }
        },
        computed: {
          categoriesTree() {
            return this.$store.state.general.categoriesTree;
          }
        },
        methods: {
          search() {
            // reset shop filter
            this.$store.dispatch('general/resetShopFilter');

            this.$store.commit('general/setKeyword', this.keyword);

            this.$router.push({ path: "/search", query: {keyword: this.keyword}});

            this.$store.dispatch('general/fetchShopProducts');
          }
        },
        mounted() {
          this.$store.dispatch('general/fetchCategoryTree');
        }
    }
</script>

<style scoped>
  .search_box input {
    font-size: 18px;
    color: #424040;
  }
</style>

Now the categoriesTree will be loaded once in the mounted hook of the <FrontHeader /> component and updated into the store.

 

Updating The Search And Category Pages

After completing the shop page, there two similar pages works like the shop which are the search and category pages.

pages/search.vue

<template>
  <section>
    <div class="container">
      <h2 class="title text-left">Search results for "{{ this.keyword }}"</h2>
      <div class="row">
        <div class="col-sm-3">
          <ShopSidebar pageType="search"></ShopSidebar>
        </div>

        <div class="col-sm-9 padding-right" v-if="this.products.data && this.products.data.length">
          <div class="features_items">

            <div class="col-sm-4" v-for="(item, index) in this.products.data" :key="index">
              <ProductTemplateNormal :item="item"></ProductTemplateNormal>
            </div>

            <FrontPagination :data="this.products" v-on:handlePagination="paginate"></FrontPagination>
          </div>
        </div>
        <div class="col-sm-9 padding-right" v-else>
          <p class="text-center no-products"><i class="fa fa-exclamation-triangle"></i> No products available found that match your search criteria!</p>
        </div>
      </div>
    </div>
  </section>
</template>

<script>
  import ShopSidebar from "../components/shop-components/ShopSidebar";
  import ProductTemplateNormal from "../components/product-templates/ProductTemplateNormal";
  import FrontPagination from "../components/helpers/FrontPagination";
  import {paginate} from "../helpers/functions";

  export default {
    name: "Search",
    components: {
      FrontPagination,
      ProductTemplateNormal,
      ShopSidebar
    },
    data() {
      return {
         keyword: ""
      }
    },
    head() {
      return {
        title: 'Online Shop | Search',
        meta: [
          {
            hid: 'description',
            name: 'description',
            content: 'Search Page'
          }
        ]
      }
    },
    computed: {
      products() {
        return this.$store.state.general.shop.products;
      },
      page() {
        return this.$store.state.general.shop.page;
      }
    },
    methods: {
      paginate(page_number) {
        paginate(this, page_number);
      }
    },
    mounted() {
      // reset shop filter
      this.$store.dispatch('general/resetShopFilter');


      if (this.$route.query.keyword) {
        this.keyword = this.$route.query.keyword;

        this.$store.commit('general/setKeyword', this.$route.query.keyword);
      }

      if (this.$route.query.page) {
        this.$store.commit('general/setPage', this.$route.query.page);
      }

      if (this.$route.query.from_price) {
        this.$store.commit('general/setFromPrice', this.$route.query.from_price);
      }

      if (this.$route.query.to_price) {
        this.$store.commit('general/setToPrice', this.$route.query.to_price);
      }

      this.$nextTick(() => {
        this.$nuxt.$loading.start();

        this.$store.dispatch('general/fetchShopProducts').then(() => {
          this.$nuxt.$loading.finish();
        });
      });
    }
  }
</script>

<style scoped>
  .col-sm-4 {
    height: 437px !important;
    margin-bottom: 30px !important;
  }
  .no-products {
    color: #696763;
    font-size: 15px;
    font-family: 'Roboto', sans-serif;
  }
</style>

pages/category/_id/_slug/index.vue

<template>
  <section>
    <div class="container">
      <h2 class="title text-left">Latest Items In {{ this.category ? this.category.title : "" }}</h2>
      <div class="row">
        <div class="col-sm-3">
          <ShopSidebar pageType="category"></ShopSidebar>
        </div>

        <div class="col-sm-9 padding-right" v-if="this.products.data && this.products.data.length">
          <div class="features_items">

            <div class="col-sm-4" v-for="(item, index) in this.products.data" :key="index">
              <ProductTemplateNormal :item="item"></ProductTemplateNormal>
            </div>

            <FrontPagination :data="this.products" v-on:handlePagination="paginate"></FrontPagination>
          </div>
        </div>
        <div class="col-sm-9 padding-right" v-else>
          <p class="text-center no-products"><i class="fa fa-exclamation-triangle"></i> No products available in this category! Check back later</p>
        </div>
      </div>
    </div>
  </section>
</template>

<script>
    import ShopSidebar from "../../../../components/shop-components/ShopSidebar";
    import ProductTemplateNormal from "../../../../components/product-templates/ProductTemplateNormal";
    import FrontPagination from "../../../../components/helpers/FrontPagination";
    import {paginate} from "../../../../helpers/functions";

    export default {
        name: "Category",
        validate({params}) {
          return /^\d+$/.test(params.id);
        },
        components: {
            FrontPagination,
            ProductTemplateNormal,
            ShopSidebar
       },
      head() {
          return {
            title: 'Online Shop | ' + (this.categoryDetails ? this.categoryDetails.title : ""),
            meta: [
              {
                hid: 'description',
                name: 'description',
                content: (this.categoryDetails ? ( this.categoryDetails.description ? this.categoryDetails.description : this.categoryDetails.title ) : "")
              }
            ]
          }
      },
      asyncData(context) {
        context.store.dispatch('general/resetShopFilter');
        context.store.commit('general/setCategoryId', context.params.id);
        return context.store.dispatch('general/fetchCategory', context.params.id).then((category) => {
          return {
            categoryDetails: category
          }
        });
      },
      computed: {
        category() {
          return this.$store.state.general.category;
        },
        products() {
          return this.$store.state.general.shop.products;
        },
        page() {
          return this.$store.state.general.shop.page;
        }
      },
      methods: {
        paginate(page_number) {
          paginate(page_number);
        }
      },
      mounted() {
         // load brands by this category
         this.$store.dispatch('general/fetchBrandsByCategory', this.$route.params.id);

         if (this.$route.query.page) {
           this.$store.commit('general/setPage', this.$route.query.page);
         }

         if (this.$route.query.brand_id) {
           this.$store.commit('general/setBrand', this.$route.query.brand_id);
         }

         if (this.$route.query.from_price) {
           this.$store.commit('general/setFromPrice', this.$route.query.from_price);
         }

         if (this.$route.query.to_price) {
           this.$store.commit('general/setToPrice', this.$route.query.to_price);
         }

         this.$nextTick(() => {
           this.$nuxt.$loading.start();

           this.$store.dispatch('general/fetchShopProducts').then(() => {
             this.$nuxt.$loading.finish();
           });
         });
      }
    }
</script>

<style scoped>
  .col-sm-4 {
    height: 437px !important;
    margin-bottom: 30px !important;
  }
  .no-products {
    color: #696763;
    font-size: 15px;
    font-family: 'Roboto', sans-serif;
  }
</style>

In the category page using Nuxtjs validate() method to validate for the route parameters, in this case i check that the id is numeric otherwise 404 page will be shown.

Next we update the head() method to render dynamic category title and description in the meta tags. But for this to work properly we need to fetch the category details into the asyncData() method, this also a Nuxt method.

asyncData() works just like the normal Vue data() method but the asyncData() return response from async requests like http requests, and this response is latter merged with the returned data from data() method, in this case we we called the category details into asyncData() method. The asyncData() is fired early before the mounted() hook so it’s a suitable place to fetch data that is needed in the early load of the page like fetching the meta data.

Note that for proper rendering the head() data it’s better to use the asyncData() hook that comes with Nuxtjs instead of using the mounted() hook.

 

 

Continue to Part14: Product Details Page

 

0 0 votes
Article Rating

What's your reaction?

Excited
0
Happy
1
Not Sure
0
Confused
0

You may also like

Subscribe
Notify of
guest

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments