Backend DevelopmentFrontend DevelopmentVueJs Tutorials

Building Ecommerce Website With Lumen Laravel And Nuxtjs 8: Admin Products Management

Building Ecommerce Website With PHP Lumen Laravel And Nuxtjs

In this article we will add the product management pages in Nuxt admin panel, like create, update and display all products so that our product module is completed.

 

 

 

First go to the Nuxt project we want to install two npm packages:

npm install vuejs-datepicker vue-quill-editor

These are a vuejs datepicker and vue rich text editor. We will need those components when manipulating product discount dates and the description fields.

After successful installation go to the /admin/ folder and edit nuxt.config.js to add the css files for vue-quill-editor. Find the css section as shown below:

nuxt.config.js

 /*
  ** Global CSS
  */
  css: [
    'quill/dist/quill.core.css',
    'quill/dist/quill.snow.css',
    'quill/dist/quill.bubble.css'
  ],

Now you need to re-run

npm run dev

With these preparations set up let’s move on to create the product apis.

 

Product Apis

In the /admin/api/ folder create a new file product.js and add this below code:

admin/api/product.js

const ProductApi = {
  create: (axios, payload) => {
    return axios.$post('/api/product', ProductApi.toFormData(payload));
  },
  list: (axios, payload) => {
    let payload_arr = [];

    if(payload) {
      for(let key in payload) {
        payload_arr.push(key +"=" + payload[key]);
      }
    }

    return axios.$get('/api/product?' + payload_arr.join("&"))
  },
  delete: (axios, id) => {
    return axios.$delete('/api/product/' + id);
  },
  show: (axios, id) => {
    return axios.$get('/api/product/' + id);
  },
  update: (axios, payload, id) => {
    const data = ProductApi.toFormData(payload);
    data.append('_method', 'put');

    return axios.$post('/api/product/' + id, data, {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      }
    });
  },
  deleteImage: (axios, id) => {
    return axios.$delete('/api/product/delete-image/' + id);
  },
  toFormData(payload) {
    const formData = new FormData();
    for(let field in payload.product) {
      formData.append(field, payload.product[field]);
    }
    for (let i=0; i<payload.features.length; i++) {
      formData.append('features[' + payload.features[i].field_id + ']', payload.features[i].field_value);
    }
    for (let i=0; i<payload.files.length; i++) {
      formData.append('image[' + i + ']', payload.files[i]);
    }

    return formData;
  }
}

export {ProductApi};

We have six apis here (create, list, show, delete, update, deleteImage) which corresponds to the CRUD operations we created in the previous part. We follow the same approach as we did with brands apis, except for the create and update apis we send the data as a FormData object because we have uploaded files to be sent.

For this purpose i added toFormData() function above which takes the raw object and return formData object.

 

Preparing Pages

We will create three pages for the product module, (indexcreate_edit), so in the /admin/pages/ create a new directory named product and add these pages:

  • index.vue: The index page to display all products
  • create.vue: The create product page
  • _edit.vue: The edit product page

Once you created these pages Nuxtjs automatically generates the routes for the product module. Now let’s prepare the UI for each page with some dummy data.

admin/pages/product/index.vue

<template>
  <div class="main-content">
    <div class="section__content section__content--p30">
      <div class="container-fluid">
        <div class="row">
          <div class="col-md-12">
            <loader></loader>
            <status-messages></status-messages>

          </div>
          <div class="col-md-12">
            <!-- DATA TABLE-->
            <div class="table-responsive m-b-40">
              <table class="table table-borderless table-data3">
                <thead>
                <tr>
                  <th>#</th>
                  <th>Title</th>
                  <th>Images</th>
                  <th>Price</th>
                  <th>Inventory</th>
                  <th>Category</th>
                  <th>By</th>
                  <th>Options</th>
                </tr>
                </thead>
                <tbody>
                  <tr>
    <td>1</td>
    <td>Samsung 32 Inch HD Smart TV with Built-in Receiver - Black, UA32N5300ASXEG</td>
    <td></td>
    <td><span class="badge badge-info">200 $</span></td>
    <td><strong class="inventory">10</strong></td>
    <td>Mobiles</td>
    <td>admin</td>
    <td>

      <ul class="nav nav-tabs options-dropdown">
        <li class="nav-item dropdown">
          <a class="nav-link" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false">
            <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-three-dots-vertical" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
              <path fill-rule="evenodd" d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
            </svg>
          </a>
          <div class="dropdown-menu">
            <a href="#" class="dropdown-item btn btn-info btn-sm"><i class="fa fa-edit"></i> Edit</a>
            <a href="#" class="dropdown-item btn btn-danger btn-sm"><i class="fa fa-remove"></i> Remove</a>
          </div>
        </li>
      </ul>
    </td>
  </tr>
                </tbody>
              </table>
            </div>
            <Pagination :data="productList" v-if="productList.data" v-on:handlePagination="handlePagination"></Pagination>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
    import Loader from "../../components/helpers/loader";
    import StatusMessages from "../../components/helpers/statusMessages";
    import Pagination from "../../components/helpers/Pagination";
    export default {
        name: "index",
      components: {
        Pagination,
          StatusMessages,
          Loader
      },
      fetch() {

      },
      computed: {
        productList() {
          return this.$store.state.product.product_list;
        }
      },
      methods: {
        handlePagination(page_number) {
          
        }
      }
    }
</script>

<style scoped>

</style>

admin/pages/product/create.vue

<template>
  <div class="main-content">
    <div class="section__content section__content--p30">
      <div class="container-fluid">
        <loader></loader>
        <status-messages></status-messages>
        <form method="post" action="#" @submit.prevent="save()" enctype="multipart/form-data">
          <ProductForm></ProductForm>

          <div class="row">
            <button type="submit" class="btn btn-lg btn-info btn-block">
              <i class="fa fa-save fa-lg"></i> Save
            </button>
          </div>
        </form>
      </div>
    </div>
  </div>
</template>

<script>
    import Loader from '../../components/helpers/loader';
    import statusMessages from '../../components/helpers/statusMessages';
    import ProductForm from "../../components/product-components/Form";

    export default {
        name: "create",
        components: {
          ProductForm,
          Loader,
          statusMessages
        },
        fetch() {
        },
        methods: {
          save() {
          }
        },
        mounted() {
        }
    }
</script>

<style scoped>

</style>

admin/pages/product/_edit.vue

<template>
  <div class="main-content">
    <div class="section__content section__content--p30">
      <div class="container-fluid">
        <loader></loader>
        <status-messages></status-messages>
        <form method="post" action="#" @submit.prevent="update()" enctype="multipart/form-data">
          <ProductForm></ProductForm>

          <div class="row">
            <button type="submit" class="btn btn-lg btn-info btn-block">
              <i class="fa fa-save fa-lg"></i> Update
            </button>
          </div>
        </form>
      </div>
    </div>
  </div>
</template>

<script>
    import Loader from "../../components/helpers/loader";
    import StatusMessages from "../../components/helpers/statusMessages";
    import ProductForm from "../../components/product-components/Form";
    export default {
        name: "EditProduct",
        data() {
          return {
            id: ""
          }
        },
        components: {
            ProductForm,
            StatusMessages,
            Loader
        },
        fetch() {
        },
        methods: {
          update() {
            
          }
        },
        mounted() {

        }
    }
</script>

<style scoped>

</style>

admin/components/product-components/Form.vue

<template>
  <div class="row">
    <div class="col-lg-6">
      <div class="card">
        <div class="card-header">
          <strong>{{ this.$route.params.edit?'Edit Product #' + this.$route.params.edit : 'Create New Product' }}</strong>
        </div>
        <div class="card-body card-block">
          <div class="form-group">
            <label for="title" class=" form-control-label">Title <span class="required-in">*</span></label>
            <input type="text" id="title" name="title" placeholder="Enter product title" class="form-control" />
          </div>
          <div class="form-group">
            <label for="category_id" class=" form-control-label">Category <span class="required-in">*</span></label>
            <select name="category_id" id="category_id" class="form-control">

            </select>
          </div>
          <div class="form-group">
            <label for="product_code" class=" form-control-label">Product Code</label>
            <input type="text" id="product_code" name="product_code" placeholder="Product code" class="form-control" />
          </div>
          <div class="form-group">
            <label for="brand_id" class=" form-control-label">Brand </label>
            <select name="brand_id" id="brand_id" class="form-control">
              <option value="">none</option>
            </select>
          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-6">
      <div class="card">
        <div class="card-header">
          <strong>Pricing details</strong>
        </div>
        <div class="card-body">
          <label for="price" class=" form-control-label">Price <span class="required-in">*</span></label>
          <div class="input-group">
            <input type="text" id="price" name="price" placeholder="Price" class="form-control" />
            <div class="input-group-append">
              <span class="input-group-text">$</span>
            </div>
          </div>
          <div class="form-group">
            <label for="amount" class=" form-control-label">Amount <span class="required-in">*</span></label>
            <input type="number" id="amount" name="amount" placeholder="Amount" class="form-control"/>
          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Description</strong>
        </div>
        <div class="card-body">
          <div class="form-group">
            <label class=" form-control-label">Description</label>
              <textarea id="description" name="description" class="form-control" rows="8" ></textarea>
          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Product Photos</strong>
          <span style="font-size: 12px">(Select multiple images)</span>
        </div>
        <div class="card-body">
              <div class="row" id="preview">
                
              </div>
              <input type="file" class="form-control" name="image[]" multiple accept="image/*" />
          </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Features</strong>
        </div>
        <div class="card-body">
          
        </div>
      </div>
    </div>
    <div class="col-lg-12 discount-wrapper">
      <div class="discount-badge">0 %</div>
      <div class="card">
        <div class="card-header">
          <strong>Discount details</strong>
        </div>
        <div class="card-body">
          <div class="row">
            <div class="col-lg-2">
              <div class="form-group">
                <label for="discount" class=" form-control-label">Discount</label>
                <input type="number" id="discount" name="discount" class="form-control" min="0" max="100"/>
              </div>
            </div>
            <div class="col-lg-5">
              <div class="form-group">
                <label class=" form-control-label">Discount start date</label>
                <input type="text" name="discount_start_date" class="form-control" />
              </div>
            </div>
            <div class="col-lg-5">
              <div class="form-group">
                <label class=" form-control-label">Discount end date</label>
                <input type="text" name="discount_end_date" class="form-control" />
              </div>
            </div>

          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Display options</strong>
        </div>
        <div class="card-body">
          <div class="row">
            <div class="col-lg-12">
              <div class="form-group">
                <label for="featured" class=" form-control-label">Is Featured Product</label>
                <select id="featured" name="featured" class="form-control">
                  <option value="0">No</option>
                  <option value="1">Yes</option>
                </select>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>

    export default {
        name: "ProductForm",
        components: {
          
        },
        data() {
          return {
            
          }
        },
        computed: {
          product: {
            get() {
              return this.$store.state.product.product;
            }
          }
        },
        methods: {
          
        },
        mounted() {

        }
    }
</script>

<style>
  .required-in {
    color: red;
  }

  .calendar-view {
    top: -200px !important;
  }

  .photo_preview {
    width: 90px;
    height: 130px;
  }

  .discount-wrapper {
    position: relative;
  }

  .discount-badge {
    background: red;
    position: absolute;
    width: 88px;
    height: 34px;
    z-index: 9999;
    right: 17px;
    top: 9px;
    color: #fff;
    text-align: center;
    font-size: 22px;
    font-weight: bold;
  }

  .photo-wrapper {
    text-align: center;
    border: 2px solid #ccc;
    margin: 8px;
  }
</style>

Great as you see above the three pages for our product module, for the create and update page i have extracted the form into a separate component <ProductForm /> so that we can reuse it in both create and update pages instead of rewriting the form code twice. Now before we dig into each page let’s prepare the vuex store in /admin/store directory in the next section.

 

Update Product Link In Nav Menu

admin/components/partials/NavMenu.vue

<template>
  <ul :class="ulclass">
    <li :class="{active: this.$route.path === '/'}">
      <nuxt-link to="/">
        <i class="fas fa-tachometer-alt"></i>Dashboard
      </nuxt-link>
    </li>
    <li :class="{active: this.$route.path.indexOf('category') !== -1}">
      <nuxt-link to="/category">
        <i class="fas fa-list"></i>Categories
      </nuxt-link>
    </li>
    <li :class="{active: this.$route.path.indexOf('brand') !== -1}">
    <nuxt-link to="/brand">
      <i class="fas fa-television"></i>Brands
    </nuxt-link>
    </li>
    <li :class="{active: this.$route.path.indexOf('product') !== -1}">
      <nuxt-link to="/product">
        <i class="fas fa-shopping-cart"></i>Products
      </nuxt-link>
    </li>
    <li>
      <a href="#">
        <i class="fas fa-gears"></i>Orders</a>
    </li>
    <li>
      <a href="#">
        <i class="fas fa-gears"></i>Pending Orders</a>
    </li>
    <li>
      <a href="#">
        <i class="fas fa-users"></i>Users</a>
    </li>
    <li>
      <a href="#">
        <i class="fas fa-sign-out-alt"></i>Logout</a>
    </li>
  </ul>
</template>

<script>
    export default {
        name: "nav-menu",
        props: ['ulclass'],
        mounted() {
        }
    }
</script>

<style scoped>

</style>

 

Preparing Product Store

Create /admin/store/product.js and add this code:

/admin/store/product.js

import {ProductApi} from '../api/product';

export const state = () => ({
  product: {
    title: '',
    description: '',
    price: '',
    amount: '',
    discount: '',
    discount_start_date: '',
    discount_end_date: '',
    category_id: '',
    product_code: '',
    brand_id: '',
    featured: 0
  },
  files: [],
  files_preview: [],
  features: [],
  product_list: {},
  page: 1,
  filterData: {
    id: '',
    title: '',
    category_id: '',
    from_price: '',
    to_price: '',
    amount: '',
    product_code: '',
    brand_id: '',
  },
  gallery: []
});

export const mutations = {
  setProduct(state, payload) {
    
  },
  setFiles(state, files) {
    
  },
  appendFilesPreview(state, base64) {

  },
  clearFilesPreview(state) {

  },
  appendToFeatures(state, data) {

  },
  resetFeatures(state) {

  },
  updateFeatureValue(state, data) {
    
  },
  resetProduct(state) {
    
  },
  setProductList(state, products) {

  },
  setPage(state, page) {

  },
  setFilterData(state, value) {

  },
  setGallery(state, data) {

  }
};

export const actions = {
  create({commit, state, dispatch}, payload) {
    
  },
  listProducts({commit}, payload = null) {
    
  },
  delete({commit, state}, id) {
    
  },
  show({commit, state, dispatch, rootState}, id) {
    
  },
  update({commit, dispatch, state}, payload) {
    
  },
  removeImage({commit, state}, id) {
    
  },
  showValidationErrors({commit}, err) {
    
  }
};

The store state consists of:

  • product: The product object is used to store product details in the create or update form,
  • files: An array files when uploading product images in the create or update form.
  • files_preview: An array of files preview when selecting files in the create or update form.
  • features: Array of features to send when creating or updating product.
  • product_list: Represent product list in the index page
  • page: Represent current page used alongside pagination in the index page
  • filterData: This object used to filter products.
  • gallery: Array of product images to display them when fetching single product in the update page

Store Mutations:

  • setProduct: Used to set any of the product object keys like title, description, etc.
  • setFiles: Used to set the selected files when selecting files from the upload dialog
  • appendFilesPreview: Used to append files preview to the files_preview array.
  • clearFilesPreview: Used to clear files_preview array.
  • appendToFeatures: Used to append feature to the features array, this is in the create or update form.
  • resetFeatures: Reset or clear the features array.
  • updateFeatureValue: Used to update specific value in the features array.
  • resetProduct: Used to reset product object, reset files, reset files_preview, reset features, reset gallery.
  • setProductList: Used to set product_list item
  • setPage: Set page value for pagination purposes.
  • setFilterData: Used to set any key in the filterData object.
  • setGallery: Used to set the gallery.

 

Displaying All Products

First Update /admin/store/product.js in the mutations object update these mutations like this:

setProductList(state, products) {
    state.product_list = products;
  },
  setPage(state, page) {
    state.page = page;
  },
  setFilterData(state, value) {
    state.filterData[value.key] = value.val;
  },

Also in the same file in the actions object update listProducts() and delete() action as shown:

listProducts({commit}, payload = null) {
    ProductApi.list(this.$axios, payload).then(response => {
      commit('setProductList', response.products);
    });
  },
  delete({commit, state}, id) {
    commit('shared/resetStatusMessagesParameters', null, {root: true});
    commit('shared/setStatusMessageParameter', {key: 'showLoading', val: true}, {root: true});

    ProductApi.delete(this.$axios, id).then(response => {
      commit('shared/setStatusMessageParameter', {key: 'showLoading', val: false}, {root: true});
      if(response.success) {

        let productList = {...state.product_list};
        productList.data = productList.data.filter(item => item.id !== id);

        commit('setProductList', productList);
        commit('shared/setStatusMessageParameter', {key: 'success_message', val: response.message}, {root: true});
      }
    }).catch(err => {
      console.log(err);
    }).finally(() => {
      setTimeout(() => {
        commit('shared/resetStatusMessagesParameters', null, {root: true});
      }, 5000);
    });
  }

Now let’s update pages/product/index.vue

<template>
  <div class="main-content">
    <div class="section__content section__content--p30">
      <div class="container-fluid">
        <div class="row">
          <div class="col-md-12">
            <loader></loader>
            <status-messages></status-messages>

            <ProductFilter v-on:Filtering="handleFiltering"></ProductFilter>

          </div>
          <div class="col-md-12">

            <div class="table-responsive m-b-40">
              <table class="table table-borderless table-data3">
                <thead>
                <tr>
                  <th>#</th>
                  <th>Title</th>
                  <th>Images</th>
                  <th>Price</th>
                  <th>Inventory</th>
                  <th>Category</th>
                  <th>By</th>
                  <th>Options</th>
                </tr>
                </thead>
                <tbody>
                  <ProductRow v-if="productList.data && productList.data.length > 0" v-for="product of productList.data" v-bind:product="product" v-bind:key="product.id" v-on:removeProduct="removeProduct"></ProductRow>
                  <tr v-if="productList.data && productList.data.length === 0"><td colspan="8" class="text-center">No results found</td></tr>
                </tbody>
              </table>
            </div>
            <Pagination :data="productList" v-if="productList.data" v-on:handlePagination="handlePagination"></Pagination>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
    import ProductFilter from "../../components/product-components/ProductFilter";
    import Loader from "../../components/helpers/loader";
    import StatusMessages from "../../components/helpers/statusMessages";
    import ProductRow from "../../components/product-components/ProductRow";
    import Pagination from "../../components/helpers/Pagination";
    export default {
        name: "index",
      components: {
        Pagination,
          ProductRow,
          StatusMessages,
          Loader,
          ProductFilter
      },
      fetch() {
        this.$store.dispatch('product/listProducts');
        this.$store.dispatch('category/getCategoryHtmlTree');
        this.$store.dispatch('brand/getAllBrands');
      },
      computed: {
        productList() {
          return this.$store.state.product.product_list;
        }
      },
      methods: {
        handleFiltering(field, value) {
          this.$store.commit('product/setFilterData', {key: field, val: value});
          this.$store.commit('product/setPage', 1);

          this.$store.dispatch('product/listProducts', this.getPayload());
        },
        removeProduct(id) {
          if(confirm("Are you sure?")) {
            this.$store.dispatch('product/delete', id);
          }
        },
        handlePagination(page_number) {
          this.$store.commit('product/setPage', page_number);

          this.$store.dispatch('product/listProducts', this.getPayload());
        },
        getPayload() {
          let payload = {...this.$store.state.product.filterData};

          Object.keys(payload).forEach(key => payload[key] === '' && delete payload[key]);

          payload.page = this.$store.state.product.page;

          return payload;
        }
      },
      mounted() {
         // re-initialize tabs
        setTimeout(() => {
          if($('.options-dropdown').length) {
            $('.options-dropdown').tab();
          }
        }, 300);
      }
    }
</script>

<style scoped>

</style>

There are a lot of stuff in the code above but don’t worry, at first we are using some partial components to make things simple which are <ProductFilter /> which is a filter form displayed in top of the product list and <ProductRow /> for displaying a single product row.

The <ProductFilter /> accept a custom event “Filtering” to be fired when changing any field to filter. Also <ProductRow /> accept a product prop and a custom event used when removing single product.

Then in the fetch() hook powered by Nuxtjs we dispatch some actions like “product/listProducts”, “category/getCategoryHtmlTree”, and “brand/getAllBrands”.

I added a computed property productList which we get from this.$store.state.product.product_list.

The handleFiltering() method fires when we trigger the filtering on the filter form. The removeProduct() method used when we click the remove button beside each product row.

In the mounted() hook i re-initialized ‘.options-dropdown‘, this is a bootstrap component that you need to refresh upon entering the page, otherwise it won’t work properly.

Now let’s see the code for the partial components:

admin/components/product-components/ProductFilter.vue

<template>
  <form method="get" action="#" style="margin-bottom: 10px">
    <h4>Filter</h4>
    <div class="row">
      <div class="col-1">
        <input type="text" class="form-control" placeholder="Id" name="id" @change="handleFiltering($event)" :value="this.$store.state.product.filterData.id">
      </div>
      <div class="col-3">
        <input type="text" class="form-control" placeholder="Title" name="title" @change="handleFiltering($event)" :value="this.$store.state.product.filterData.title">
      </div>
      <div class="col-3">
        <select class="form-control" v-html="this.$store.state.category.categoryHtmlTree" name="category_id" @change="handleFiltering($event)" :value="this.$store.state.product.filterData.category_id">
          <option value="">select category</option>
        </select>
      </div>
      <div class="col-2">
        <input type="text" class="form-control" placeholder="From price" name="from_price" @change="handleFiltering($event)" :value="this.$store.state.product.filterData.from_price">
      </div>
      <div class="col-2">
        <input type="text" class="form-control" placeholder="To price" name="to_price" @change="handleFiltering($event)" :value="this.$store.state.product.filterData.to_price">
      </div>
      <div class="col-2">
        <input type="text" class="form-control" placeholder="Product code" name="product_code" @change="handleFiltering($event)" :value="this.$store.state.product.filterData.product_code">
      </div>
      <div class="col-2">
        <input type="text" class="form-control" placeholder="Inventory" name="amount" @change="handleFiltering($event)" :value="this.$store.state.product.filterData.amount">
      </div>
      <div class="col-3">
        <select name="brand_id" id="brand_id" class="form-control" :value="this.$store.state.product.filterData.brand_id" @change="handleFiltering($event)">
          <option value="">Brand</option>
          <option v-for="brand in this.$store.state.brand.allBrands" :key="brand.id" :value="brand.id">{{ brand.title }}</option>
        </select>
      </div>
    </div>
    <div class="row">
      <div class="col-12">
        <nuxt-link to="/product/create" class="btn btn-success pull-right"><i class="fa fa-plus-square"></i> Create</nuxt-link>
      </div>
    </div>
  </form>
</template>

<script>
    export default {
        name: "ProductFilter",
        methods: {
          handleFiltering(event) {
            this.$emit('Filtering', event.target.name, event.target.value);
          }
        }
    }
</script>

<style scoped>
  .col-3, .col-2, .col-1 {
    padding: 2px !important;
  }
</style>

admin/components/product-components/ProductRow.vue

<template>
  <tr>
    <td>{{ this.product.id }}</td>
    <td>{{ this.product.title }}</td>
    <td><div class="row" v-html="displayImages(this.product)"></div></td>
    <td><span class="badge badge-info">{{ this.product.price }} $</span></td>
    <td><strong class="inventory">{{ this.product.amount }}</strong></td>
    <td><nuxt-link :to="'/category/' + this.product.category_id">{{ this.product.category.title }}</nuxt-link></td>
    <td>{{ this.product.user?this.product.user.name:"" }}</td>
    <td>

      <ul class="nav nav-tabs options-dropdown">
        <li class="nav-item dropdown">
          <a class="nav-link" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false">
            <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-three-dots-vertical" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
              <path fill-rule="evenodd" d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
            </svg>
          </a>
          <div class="dropdown-menu">
            <nuxt-link :to="'/product/' + this.product.id" class="dropdown-item btn btn-info btn-sm"><i class="fa fa-edit"></i> Edit</nuxt-link>
            <a href="#" class="dropdown-item btn btn-danger btn-sm" @click.prevent="removeProduct(product.id)"><i class="fa fa-remove"></i> Remove</a>
          </div>
        </li>
      </ul>
    </td>
  </tr>
</template>

<script>
    export default {
        name: "ProductRow",
        props: ['product'],
        methods: {
          removeProduct(productId) {
            this.$emit('removeProduct', productId);
          },
          displayImages(product) {
            let images = "";

            for(let i = 0; i < product.gallery.length; i++) {
              images += `<div class="col-md-8" style="margin: 5px"><img src="${product.gallery[i].image_url.small}" /></div>`;
            }

            return images;
          }
        }
    }
</script>

<style scoped>
  .inventory {
    color: red;
  }

  .options-dropdown {
    font-size: 29px;
  }

  .options-dropdown svg {
    color: #000;
  }
</style>

 

Creating Products

Perhaps creating products may seem to be a long process because it has a lot of mutations but it’s not if you imagine the process, so in /admin/store/product.js let’s update the below mutations:

setProduct(state, payload) {
    setProduct(state, payload) {
    if(payload.key === 'price') {
      payload.value = parseFloat(payload.value);
    } else if(payload.key === 'amount' || payload.key === 'discount') {
        payload.value = parseInt(payload.value);

      if(payload.key === 'discount' && (payload.value > 100 || payload.value < 0)) {
        payload.value = 0;
      }
    }

    state.product[payload.key] = payload.value;
  },
  setFiles(state, files) {
    let files_array = [];

    for(let i=0; i<files.length; i++) {
      files_array.push(files[i]);
    }
    state.files = files_array;
  },
  appendFilesPreview(state, base64) {
    state.files_preview.push(base64);
  },
  clearFilesPreview(state) {
    state.files_preview = [];
  },
  appendToFeatures(state, data) {
    state.features.push(data);
  },
  resetFeatures(state) {
    state.features = [];
  },
  updateFeatureValue(state, data) {
    state.features.map(feature => {
      if(feature.field_id === data.id) {
        feature.field_value = data.value;
      }
    });
  },
  resetProduct(state) {
    for(let key in state.product) {
      state.product[key] = '';
    }

    state.files = [];
    state.files_preview = [];
    state.features = [];
    state.gallery = [];
  },
  setGallery(state, data) {
    state.gallery = data;
  }

In the same file update also the create action in the actions object as shown:

create({commit, state, dispatch}, payload) {
    commit('shared/resetStatusMessagesParameters', null, {root: true});
    commit('shared/setStatusMessageParameter', {key: 'showLoading', val: true}, {root: true});

    const dataToSend = {};
    dataToSend.product = state.product;
    dataToSend.features = state.features;
    dataToSend.files = state.files;

    ProductApi.create(this.$axios, dataToSend).then(response => {
      commit('shared/setStatusMessageParameter', {key: 'showLoading', val: false}, {root: true});
      if(response.success) {
        commit('shared/setStatusMessageParameter', {key: 'success_message', val: response.message}, {root: true});
      }

      setTimeout(() => {
        commit('resetProduct');
        payload.router.push('/product');
      }, 2000);

    }).catch(err => {
      dispatch('showValidationErrors', err);
    }).finally(() => {
      setTimeout(() => {
        commit('shared/resetStatusMessagesParameters', null, {root: true});
        // commit('resetProduct');
      }, 10000);
    });
  }

The showValidationErrors() action used to show error messages when creating or updating products. Add this action also after the create action:

showValidationErrors({commit}, err) {
    commit('shared/setStatusMessageParameter', {key: 'showLoading', val: false}, {root: true});
    if(err.response.data) {
      commit('shared/setStatusMessageParameter', {key: 'error_message', val: err.response.data.message}, {root: true});
      if(err.response.data.errors) {
        let errors = [];
        for(let key in err.response.data.errors) {
          errors.push(err.response.data.errors[key][0]);
        }

        commit('shared/setStatusMessageParameter', {key: 'validation_errors', val: errors}, {root: true});
      }
    }
  }

 

Now let’s update <ProductForm /> component.

admin/component/product-components/Form.vue

<template>
  <div class="row">
    <div class="col-lg-6">
      <div class="card">
        <div class="card-header">
          <strong>{{ this.$route.params.edit?'Edit Product #' + this.$route.params.edit : 'Create New Product' }}</strong>
        </div>
        <div class="card-body card-block">
          <div class="form-group">
            <label for="title" class=" form-control-label">Title <span class="required-in">*</span></label>
            <input type="text" id="title" name="title" placeholder="Enter product title" class="form-control" :value="this.product.title" @change="updateField($event)" />
          </div>
          <div class="form-group">
            <label for="category_id" class=" form-control-label">Category <span class="required-in">*</span></label>
            <select name="category_id" id="category_id" class="form-control" :value="this.product.category_id" @change="updateField($event)" v-html="this.$store.state.category.categoryHtmlTree">

            </select>
          </div>
          <div class="form-group">
            <label for="product_code" class=" form-control-label">Product Code</label>
            <input type="text" id="product_code" name="product_code" placeholder="Product code" class="form-control" :value="this.product.product_code" @change="updateField($event)" />
          </div>
          <div class="form-group">
            <label for="brand_id" class=" form-control-label">Brand </label>
            <select name="brand_id" id="brand_id" class="form-control" :value="this.product.brand_id" @change="updateField($event)">
              <option value="">none</option>
              <option v-for="brand in this.$store.state.brand.allBrands" :key="brand.id" :value="brand.id">{{ brand.title }}</option>
            </select>
          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-6">
      <div class="card">
        <div class="card-header">
          <strong>Pricing details</strong>
        </div>
        <div class="card-body">
          <label for="price" class=" form-control-label">Price <span class="required-in">*</span></label>
          <div class="input-group">
            <input type="text" id="price" name="price" placeholder="Price" class="form-control" :value="this.product.price" @change="updateField($event)" />
            <div class="input-group-append">
              <span class="input-group-text">$</span>
            </div>
          </div>
          <div class="form-group">
            <label for="amount" class=" form-control-label">Amount <span class="required-in">*</span></label>
            <input type="number" id="amount" name="amount" placeholder="Amount" class="form-control" :value="this.product.amount" @change="updateField($event)" />
          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Description</strong>
        </div>
        <div class="card-body">
          <div class="form-group">
            <label class=" form-control-label">Description</label>
              <textarea id="description" name="description" class="form-control" rows="8" :value="this.product.description" @change="updateField($event)"></textarea>
          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Product Photos</strong>
          <span style="font-size: 12px">(Select multiple images)</span>
        </div>
        <div class="card-body">
              <div class="row" id="preview">
                <div class="col-lg-3 photo-wrapper" v-for="(preview, index) of this.$store.state.product.files_preview" :key="index">
                  <img :src="preview" class="photo_preview" />
                </div>
              </div>
              <input type="file" class="form-control" name="image[]" multiple accept="image/*" @change="handlePhotos" />

              <div class="row" v-if="this.$store.state.product.gallery.length">
                <div class="col-lg-3" v-for="(record, index) of this.$store.state.product.gallery">
                    <img :src="record.image_url.small" class="photo_preview" />
                    <a href="#" class="btn btn-danger" @click.prevent="removeImage(record.id)" title="remove"><i class="fa fa-remove"></i></a>
                </div>
              </div>
          </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Features</strong>
        </div>
        <div class="card-body">
          <div v-if="this.$store.state.product.features.length">
            <div class="form-group" v-for="feature in this.$store.state.product.features">
              <label class="form-control-label">{{ feature.field_title }}</label>
              <input :type="feature.field_type === 1 ?'text':'color'" :name="'feature[' + feature.field_id + ']'" placeholder="" class="form-control" :value="feature.field_value" @change="updateFeatureValue($event, feature.field_id)" />
            </div>
          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-12 discount-wrapper">
      <div class="discount-badge" v-if="this.product.discount > 0">{{ this.product.discount }} %</div>
      <div class="card">
        <div class="card-header">
          <strong>Discount details</strong>
        </div>
        <div class="card-body">
          <div class="row">
            <div class="col-lg-2">
              <div class="form-group">
                <label for="discount" class=" form-control-label">Discount</label>
                <input type="number" id="discount" name="discount" class="form-control" min="0" max="100" :value="this.product.discount" @change="updateField($event)" />
              </div>
            </div>
            <div class="col-lg-5">
              <div class="form-group">
                <label class=" form-control-label">Discount start date</label>
                <input type="text" id="discount_start_date" name="discount_start_date" class="form-control" min="0" max="100" :value="this.product.discount_start_date" @change="updateField($event)" />
              </div>
            </div>
            <div class="col-lg-5">
              <div class="form-group">
                <label class=" form-control-label">Discount end date</label>
                <input type="text" id="discount_end_date" name="discount_end_date" class="form-control" min="0" max="100" :value="this.product.discount_end_date" @change="updateField($event)" />
              </div>
            </div>

            <div class="col-lg-12 text-center" v-if="product.price !== '' && product.discount !== '' && product.discount > 0">
              <p>Original price: <del>{{product.price}}</del></p>
              <p>Price after discount: <strong>{{ this.getPriceWithDiscount() }}</strong></p>
            </div>

          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Display options</strong>
        </div>
        <div class="card-body">
          <div class="row">
            <div class="col-lg-12">
              <div class="form-group">
                <label for="featured" class=" form-control-label">Is Featured Product</label>
                <select id="featured" name="featured" class="form-control" :value="this.product.featured" @change="updateField($event)">
                  <option value="0">No</option>
                  <option value="1">Yes</option>
                </select>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
    export default {
        name: "ProductForm",
        components: {
          
        },
        data() {
          return {
            
          }
        },
        computed: {
          product: {
            get() {
              return this.$store.state.product.product;
            }
          }
        },
        methods: {
          updateField(e) {
            this.$store.commit('product/setProduct', {key: e.target.name, value: e.target.value});

            if(e.target.name == 'category_id') {
              this.$store.dispatch('category/showCategory', e.target.value);

              this.$store.commit('product/resetFeatures');

              setTimeout(() => {
                if (this.$store.state.category.features.length) {
                  const features = [...this.$store.state.category.features];

                  features.map(feature => {
                    this.$store.commit('product/appendToFeatures', {
                      field_id: feature.id,
                      field_title: feature.field_title,
                      field_type: feature.field_type,
                      field_value: ''
                    });
                  });
                }
              }, 300);
            }
          },
          handlePhotos(event) {
            this.$store.commit('product/setFiles', event.target.files);

            this.previewPhotos();
          },
          previewPhotos() {
            this.$store.commit('product/clearFilesPreview');

            this.$store.state.product.files.map(file => {
              const self = this;
              var reader = new FileReader();
              reader.onload = function (e) {
                self.$store.commit('product/appendFilesPreview', e.target.result);
              }
              reader.readAsDataURL(file);

            });
          },
          updateFeatureValue(event, id) {
            this.$store.commit('product/updateFeatureValue', {id, value: event.target.value});
          },
          getPriceWithDiscount() {
            return this.product.price - (this.product.price * (this.product.discount / 100))
          },
          removeImage(id) {
            if(confirm("Are you sure?")) {
              this.$store.dispatch('product/removeImage', id);
            }
          }
        },
        mounted() {

        }
    }
</script>

<style>
  .required-in {
    color: red;
  }

  .calendar-view {
    top: -200px !important;
  }

  .photo_preview {
    width: 90px;
    height: 130px;
  }

  .discount-wrapper {
    position: relative;
  }

  .discount-badge {
    background: red;
    position: absolute;
    width: 88px;
    height: 34px;
    z-index: 9999;
    right: 17px;
    top: 9px;
    color: #fff;
    text-align: center;
    font-size: 22px;
    font-weight: bold;
  }

  .photo-wrapper {
    text-align: center;
    border: 2px solid #ccc;
    margin: 8px;
  }
</style>

I will focus on the <ProductForm /> as it contains the overall product form and we use it in both create and update. I split the form into multiple sections as you see to make it seem nice. Then i bind the form elements with the each corresponding value for example the title i optain it from this.product.title

You may notice that i don’t use v-model here because the form contains a lot of fields so instead i take another aproach by binding the value of the element and using @change event to update the input. the @change event takes a custom method called updateField() which update any field using it’s name as the key. We do this by commiting the setProduct mutation.

In the updateField() method if the field is the category_id then we retrieve the category details so that we can get this category features to render them in the features section of the product form, we do this by commiting appendToFeatures mutation which append an object of feature, later we will use this array to render our features.

The handlePhotos() and previewPhotos() used to set photos and preview them respectively. Here we can select multiple photos then handlePhotos() save them into the store using the setFiles mutation. In the previewPhotos() method we use javascript FileReader to read those photos as base64 string thereby displaying them in the preview block like this:

<div class="row" id="preview">
                <div class="col-lg-3 photo-wrapper" v-for="(preview, index) of this.$store.state.product.files_preview" :key="index">
                  <img :src="preview" class="photo_preview" />
                </div>
              </div>

The updateFeatureValue() method used to update specific feature by commiting the updateFeatureValue mutation. Inside the mutation we use javascript map to loop over the features array and look for specifc field_id thereby updating it’s value.

updateFeatureValue(state, data) {
    state.features.map(feature => {
      if(feature.field_id === data.id) {
        feature.field_value = data.value;
      }
    });
  }

The getPriceWithDiscount() method used after applying a discount value to the price if there is a discount, then we use this formula to calculate the price after deducting the discount:

return this.product.price - (this.product.price * (this.product.discount / 100));

The removeImage() remove a product image by dispatching removeImage action.

Now let’s update pages/product/create.vue:

pages/product/create.vue

<template>
  <div class="main-content">
    <div class="section__content section__content--p30">
      <div class="container-fluid">
        <loader></loader>
        <status-messages></status-messages>
        <form method="post" action="#" @submit.prevent="save()" enctype="multipart/form-data">
          <ProductForm></ProductForm>

          <div class="row">
            <button type="submit" class="btn btn-lg btn-info btn-block">
              <i class="fa fa-save fa-lg"></i> Save
            </button>
          </div>
        </form>
      </div>
    </div>
  </div>
</template>

<script>
    import Loader from '../../components/helpers/loader';
    import statusMessages from '../../components/helpers/statusMessages';
    import ProductForm from "../../components/product-components/Form";

    export default {
        name: "create",
        components: {
          ProductForm,
          Loader,
          statusMessages
        },
        fetch() {
          this.$store.dispatch('category/getCategoryHtmlTree');
          this.$store.dispatch('brand/getAllBrands');
        },
        methods: {
          save() {
            this.$store.dispatch('product/create', {router: this.$router});
          }
        },
        mounted() {
          this.$store.commit('product/resetProduct');
        }
    }
</script>

<style scoped>

</style>

In the fetch() hook above we need to retrieve the category tree and the brands so we dispatch getCatgoryHtmlTree and getAllBrands actions. The save() method is simple just we dispatch the create action and sending router instance so we can use this to navigate to the index page after successful creation.

 

Updating Products

As we finish the create process now you have a bigger look at the product form, so let’s implement the update process as this process not have something new because we will use the same <ProductForm /> component. At first we need to update the store by updating actions show() and update() as shown below:

show({commit, state, dispatch, rootState}, id) {
    commit('shared/resetStatusMessagesParameters', null, {root: true});
    commit('shared/setStatusMessageParameter', {key: 'showLoading', val: true}, {root: true});
    commit('resetProduct');

    ProductApi.show(this.$axios, id).then(response => {
      commit('shared/setStatusMessageParameter', {key: 'showLoading', val: false}, {root: true});

      if(response.product) {

        // set product details
        for(let key in state.product) {
          if(key === 'discount' && response.product[key] == null) {
            response.product[key] = 0;
          }

          if(key === 'discount_start_date' && response.product[key] == null) {
            response.product[key] = "";
          }

          if(key === 'discount_end_date' && response.product[key] == null) {
            response.product[key] = "";
          }

          if(key === 'brand_id' && response.product[key] == null) {
            response.product[key] = "";
          }

          commit('setProduct', {key, value: response.product[key]});
        }

        // load category to retrieve features
        dispatch('category/showCategory', state.product.category_id, {root: true});

        commit('resetFeatures');

        setTimeout(() => {
          if (rootState.category.features.length > 0) {
            const features = [...rootState.category.features];

            features.map(feature => {
              let productFeatureValue = "";
              if(response.product.features && response.product.features.find(f => f.field_id == feature.id) !== undefined) {
                productFeatureValue = (response.product.features.find(f => f.field_id == feature.id)).field_value;
              } else {
                productFeatureValue = "";
              }
              commit('appendToFeatures', {
                field_id: feature.id,
                field_title: feature.field_title,
                field_type: feature.field_type,
                field_value: productFeatureValue
              });
            });
          }
        }, 200);

        // load gallery
        if(response.product.gallery) {
          commit('setGallery', response.product.gallery);
        }
      }
    }).catch(err => {
      console.log(err);
    });
  },
  update({commit, dispatch, state}, payload) {
    commit('shared/resetStatusMessagesParameters', null, {root: true});
    commit('shared/setStatusMessageParameter', {key: 'showLoading', val: true}, {root: true});

    const dataToSend = {};
    dataToSend.product = state.product;
    dataToSend.features = state.features;
    dataToSend.files = state.files;

    ProductApi.update(this.$axios, dataToSend, payload.id).then(response => {
      commit('shared/setStatusMessageParameter', {key: 'showLoading', val: false}, {root: true});
      if(response.success) {
        commit('shared/setStatusMessageParameter', {key: 'success_message', val: response.message}, {root: true});
      }

      setTimeout(() => {
        commit('resetProduct');
        payload.router.push('/product');
      }, 2000);
    }).catch(err => {
      dispatch('showValidationErrors', err);
    }).finally(() => {
      setTimeout(() => {
        commit('shared/resetStatusMessagesParameters', null, {root: true});
      }, 10000);
    });
  }

The show() action retrieve specific product by id, the most important point in this action after retrieving the product is to set and update the product features. For this purpose i dispatch showCategory action to retrieve the product category details then i loop over the features and bind them with saved features in the product as shown in this code:

// load category to retrieve features
        dispatch('category/showCategory', state.product.category_id, {root: true});

        commit('resetFeatures');

        setTimeout(() => {
          if (rootState.category.features.length > 0) {
            const features = [...rootState.category.features];

            features.map(feature => {
              let productFeatureValue = "";
              if(response.product.features && response.product.features.find(f => f.field_id == feature.id) !== undefined) {
                productFeatureValue = (response.product.features.find(f => f.field_id == feature.id)).field_value;
              } else {
                productFeatureValue = "";
              }
              commit('appendToFeatures', {
                field_id: feature.id,
                field_title: feature.field_title,
                field_type: feature.field_type,
                field_value: productFeatureValue
              });
            });
          }
        }, 200);

Also i bind the product gallery using setGallery mutation so we can display the product photos in edit screen.

The next step is to update pages/product/_edit.vue

pages/product/_edit.vue

<template>
  <div class="main-content">
    <div class="section__content section__content--p30">
      <div class="container-fluid">
        <loader></loader>
        <status-messages></status-messages>
        <form method="post" action="#" @submit.prevent="update()" enctype="multipart/form-data">
          <ProductForm></ProductForm>

          <div class="row">
            <button type="submit" class="btn btn-lg btn-info btn-block">
              <i class="fa fa-save fa-lg"></i> Update
            </button>
          </div>
        </form>
      </div>
    </div>
  </div>
</template>

<script>
    import Loader from "../../components/helpers/loader";
    import StatusMessages from "../../components/helpers/statusMessages";
    import ProductForm from "../../components/product-components/Form";
    export default {
        name: "EditProduct",
        data() {
          return {
            id: ""
          }
        },
        components: {
            ProductForm,
            StatusMessages,
            Loader
        },
        fetch() {
          this.$store.dispatch('category/getCategoryHtmlTree');
          this.$store.dispatch('brand/getAllBrands');

          // load product details
          setTimeout(() => {
            if(this.$route.params.edit) {
              this.id = this.$route.params.edit;
              this.$store.dispatch('product/show', this.$route.params.edit);
            }
          }, 200);
        },
        methods: {
          update() {
            this.$store.dispatch('product/update', {
              id: this.id,
              router: this.$router
            });
          }
        },
        mounted() {

        }
    }
</script>

<style scoped>

</style>

The point in this page here is to capture the id from the url so that we can make an http request and fetch the product details using this id, To get this id in Nuxtjs we use this.$route.params.edit because the page name _edit.vue. Then in the update() method we send the id of the product and router.

 

 

Using Rich Text Editor and Date Picker

The last thing we will do in this article is to update the description input and discount start and end dates to use the two packages that we installed at the begining of the article quill-editor and vuejs-datepicker components. This is docs for quill-editor and this is the docs of date-picker.

I have applied them at the <ProductForm /> as shown below:

<template>
  <div class="row">
    <div class="col-lg-6">
      <div class="card">
        <div class="card-header">
          <strong>{{ this.$route.params.edit?'Edit Product #' + this.$route.params.edit : 'Create New Product' }}</strong>
        </div>
        <div class="card-body card-block">
          <div class="form-group">
            <label for="title" class=" form-control-label">Title <span class="required-in">*</span></label>
            <input type="text" id="title" name="title" placeholder="Enter product title" class="form-control" :value="this.product.title" @change="updateField($event)" />
          </div>
          <div class="form-group">
            <label for="category_id" class=" form-control-label">Category <span class="required-in">*</span></label>
            <select name="category_id" id="category_id" class="form-control" :value="this.product.category_id" @change="updateField($event)" v-html="this.$store.state.category.categoryHtmlTree">

            </select>
          </div>
          <div class="form-group">
            <label for="product_code" class=" form-control-label">Product Code</label>
            <input type="text" id="product_code" name="product_code" placeholder="Product code" class="form-control" :value="this.product.product_code" @change="updateField($event)" />
          </div>
          <div class="form-group">
            <label for="brand_id" class=" form-control-label">Brand </label>
            <select name="brand_id" id="brand_id" class="form-control" :value="this.product.brand_id" @change="updateField($event)">
              <option value="">none</option>
              <option v-for="brand in this.$store.state.brand.allBrands" :key="brand.id" :value="brand.id">{{ brand.title }}</option>
            </select>
          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-6">
      <div class="card">
        <div class="card-header">
          <strong>Pricing details</strong>
        </div>
        <div class="card-body">
          <label for="price" class=" form-control-label">Price <span class="required-in">*</span></label>
          <div class="input-group">
            <input type="text" id="price" name="price" placeholder="Price" class="form-control" :value="this.product.price" @change="updateField($event)" />
            <div class="input-group-append">
              <span class="input-group-text">$</span>
            </div>
          </div>
          <div class="form-group">
            <label for="amount" class=" form-control-label">Amount <span class="required-in">*</span></label>
            <input type="number" id="amount" name="amount" placeholder="Amount" class="form-control" :value="this.product.amount" @change="updateField($event)" />
          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Description</strong>
        </div>
        <div class="card-body">
          <div class="form-group">
            <label class=" form-control-label">Description</label>
            <quillEditor class="quill-editor"
                 :value="this.product.description"
                 @change="updateTextAreaContent($event)"
                 :options="editorOption"
            ></quillEditor>
              <!-- <textarea id="description" name="description" class="form-control" rows="8" :value="this.product.description" @change="updateField($event)"></textarea>-->
          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Product Photos</strong>
          <span style="font-size: 12px">(Select multiple images)</span>
        </div>
        <div class="card-body">
              <div class="row" id="preview">
                <div class="col-lg-3 photo-wrapper" v-for="(preview, index) of this.$store.state.product.files_preview" :key="index">
                  <img :src="preview" class="photo_preview" />
                </div>
              </div>
              <input type="file" class="form-control" name="image[]" multiple accept="image/*" @change="handlePhotos" />

              <div class="row" v-if="this.$store.state.product.gallery.length">
                <div class="col-lg-3" v-for="(record, index) of this.$store.state.product.gallery">
                    <img :src="record.image_url.small" class="photo_preview" />
                    <a href="#" class="btn btn-danger" @click.prevent="removeImage(record.id)" title="remove"><i class="fa fa-remove"></i></a>
                </div>
              </div>
          </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Features</strong>
        </div>
        <div class="card-body">
          <div v-if="this.$store.state.product.features.length">
            <div class="form-group" v-for="feature in this.$store.state.product.features">
              <label class="form-control-label">{{ feature.field_title }}</label>
              <input :type="feature.field_type === 1 ?'text':'color'" :name="'feature[' + feature.field_id + ']'" placeholder="" class="form-control" :value="feature.field_value" @change="updateFeatureValue($event, feature.field_id)" />
            </div>
          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-12 discount-wrapper">
      <div class="discount-badge" v-if="this.product.discount > 0">{{ this.product.discount }} %</div>
      <div class="card">
        <div class="card-header">
          <strong>Discount details</strong>
        </div>
        <div class="card-body">
          <div class="row">
            <div class="col-lg-2">
              <div class="form-group">
                <label for="discount" class=" form-control-label">Discount</label>
                <input type="number" id="discount" name="discount" class="form-control" min="0" max="100" :value="this.product.discount" @change="updateField($event)" />
              </div>
            </div>
            <div class="col-lg-5">
              <div class="form-group">
                <label class=" form-control-label">Discount start date</label>
                <Datepicker name="discount_start_date" format="yyyy-MM-dd" input-class="form-control" :clear-button="true" calendar-class="calendar-view" :bootstrap-styling="true" clear-button-icon="fa fa-remove" :value="this.product.discount_start_date" @input="updateStartDateField" @selected="updateStartDateField"></Datepicker>
              </div>
            </div>
            <div class="col-lg-5">
              <div class="form-group">
                <label class=" form-control-label">Discount end date</label>
                <Datepicker name="discount_end_date" format="yyyy-MM-dd" input-class="form-control" :clear-button="true" calendar-class="calendar-view" :bootstrap-styling="true" clear-button-icon="fa fa-remove" :value="this.product.discount_end_date" @input="updateEndDateField" @selected="updateEndDateField"></Datepicker>
              </div>
            </div>

            <div class="col-lg-12 text-center" v-if="product.price !== '' && product.discount !== '' && product.discount > 0">
              <p>Original price: <del>{{product.price}}</del></p>
              <p>Price after discount: <strong>{{ this.getPriceWithDiscount() }}</strong></p>
            </div>

          </div>
        </div>
      </div>
    </div>
    <div class="col-lg-12">
      <div class="card">
        <div class="card-header">
          <strong>Display options</strong>
        </div>
        <div class="card-body">
          <div class="row">
            <div class="col-lg-12">
              <div class="form-group">
                <label for="featured" class=" form-control-label">Is Featured Product</label>
                <select id="featured" name="featured" class="form-control" :value="this.product.featured" @change="updateField($event)">
                  <option value="0">No</option>
                  <option value="1">Yes</option>
                </select>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
    import Datepicker from 'vuejs-datepicker';
    import { quillEditor } from 'vue-quill-editor';

    export default {
        name: "ProductForm",
        components: {
          Datepicker,
          quillEditor
        },
        data() {
          return {
            editorOption: {
              modules: {
                toolbar: [
                  ['bold', 'italic', 'underline', 'strike'],
                  ['blockquote', 'code-block'],
                  [{'header': 1}, {'header': 2}],
                  [{'list': 'ordered'}, {'list': 'bullet'}],
                  [{'script': 'sub'}, {'script': 'super'}],
                  [{'indent': '-1'}, {'indent': '+1'}],
                  [{'direction': 'rtl'}],
                  [{'size': ['small', false, 'large', 'huge']}],
                  [{'header': [1, 2, 3, 4, 5, 6, false]}],
                  [{'font': []}],
                  [{'color': []}, {'background': []}],
                  [{'align': []}],
                  ['clean'],
                  ['link', 'image', 'video']
                ]
              }
            }
          }
        },
        computed: {
          product: {
            get() {
              return this.$store.state.product.product;
            }
          }
        },
        methods: {
          updateField(e) {
            this.$store.commit('product/setProduct', {key: e.target.name, value: e.target.value});

            if(e.target.name == 'category_id') {
              this.$store.dispatch('category/showCategory', e.target.value);

              this.$store.commit('product/resetFeatures');

              setTimeout(() => {
                if (this.$store.state.category.features.length) {
                  const features = [...this.$store.state.category.features];

                  features.map(feature => {
                    this.$store.commit('product/appendToFeatures', {
                      field_id: feature.id,
                      field_title: feature.field_title,
                      field_type: feature.field_type,
                      field_value: ''
                    });
                  });
                }
              }, 300);
            }
          },
          updateTextAreaContent(event) {
            this.$store.commit('product/setProduct', {key: 'description', value: event.html});
          },
          updateStartDateField(date) {
            if(date) {
              this.$store.commit('product/setProduct', {key: 'discount_start_date', value: date.toLocaleDateString("en-US")});
            } else {
              this.$store.commit('product/setProduct', {key: 'discount_start_date', value: ''});
            }
          },
          updateEndDateField(date) {
            if(date) {
              this.$store.commit('product/setProduct', {key: 'discount_end_date', value: date.toLocaleDateString("en-US")});
            } else {
              this.$store.commit('product/setProduct', {key: 'discount_end_date', value: ''});
            }
          },
          handlePhotos(event) {
            this.$store.commit('product/setFiles', event.target.files);

            this.previewPhotos();
          },
          previewPhotos() {
            this.$store.commit('product/clearFilesPreview');

            this.$store.state.product.files.map(file => {
              const self = this;
              var reader = new FileReader();
              reader.onload = function (e) {
                self.$store.commit('product/appendFilesPreview', e.target.result);
              }
              reader.readAsDataURL(file);

            });
          },
          updateFeatureValue(event, id) {
            this.$store.commit('product/updateFeatureValue', {id, value: event.target.value});
          },
          getPriceWithDiscount() {
            return this.product.price - (this.product.price * (this.product.discount / 100));
          },
          removeImage(id) {
            if(confirm("Are you sure?")) {
              this.$store.dispatch('product/removeImage', id);
            }
          }
        },
        mounted() {

        }
    }
</script>

<style>
  .required-in {
    color: red;
  }

  .calendar-view {
    top: -200px !important;
  }

  .photo_preview {
    width: 90px;
    height: 130px;
  }

  .discount-wrapper {
    position: relative;
  }

  .discount-badge {
    background: red;
    position: absolute;
    width: 88px;
    height: 34px;
    z-index: 9999;
    right: 17px;
    top: 9px;
    color: #fff;
    text-align: center;
    font-size: 22px;
    font-weight: bold;
  }

  .photo-wrapper {
    text-align: center;
    border: 2px solid #ccc;
    margin: 8px;
  }
</style>

 

The Overall Store Code

store/product.js

import {ProductApi} from '../api/product';

export const state = () => ({
  product: {
    title: '',
    description: '',
    price: '',
    amount: '',
    discount: '',
    discount_start_date: '',
    discount_end_date: '',
    category_id: '',
    product_code: '',
    brand_id: '',
    featured: 0
  },
  files: [],
  files_preview: [],
  features: [],
  product_list: {},
  page: 1,
  filterData: {
    id: '',
    title: '',
    category_id: '',
    from_price: '',
    to_price: '',
    amount: '',
    product_code: '',
    brand_id: '',
  },
  gallery: []
});

export const mutations = {
  setProduct(state, payload) {
    if(payload.key === 'price') {
      payload.value = parseFloat(payload.value);
    } else if(payload.key === 'amount' || payload.key === 'discount') {
        payload.value = parseInt(payload.value);

      if(payload.key === 'discount' && (payload.value > 100 || payload.value < 0)) {
        payload.value = 0;
      }
    }

    state.product[payload.key] = payload.value;
  },
  setFiles(state, files) {
    let files_array = [];

    for(let i=0; i<files.length; i++) {
      files_array.push(files[i]);
    }
    state.files = files_array;
  },
  appendFilesPreview(state, base64) {
    state.files_preview.push(base64);
  },
  clearFilesPreview(state) {
    state.files_preview = [];
  },
  appendToFeatures(state, data) {
    state.features.push(data);
  },
  resetFeatures(state) {
    state.features = [];
  },
  updateFeatureValue(state, data) {
    state.features.map(feature => {
      if(feature.field_id === data.id) {
        feature.field_value = data.value;
      }
    });
  },
  resetProduct(state) {
    for(let key in state.product) {
      state.product[key] = '';
    }

    state.files = [];
    state.files_preview = [];
    state.features = [];
    state.gallery = [];
  },
  setProductList(state, products) {
    state.product_list = products;
  },
  setPage(state, page) {
    state.page = page;
  },
  setFilterData(state, value) {
    state.filterData[value.key] = value.val;
  },
  setGallery(state, data) {
    state.gallery = data;
  }
};

export const actions = {
  create({commit, state, dispatch}, payload) {
    commit('shared/resetStatusMessagesParameters', null, {root: true});
    commit('shared/setStatusMessageParameter', {key: 'showLoading', val: true}, {root: true});

    const dataToSend = {};
    dataToSend.product = state.product;
    dataToSend.features = state.features;
    dataToSend.files = state.files;

    ProductApi.create(this.$axios, dataToSend).then(response => {
      commit('shared/setStatusMessageParameter', {key: 'showLoading', val: false}, {root: true});
      if(response.success) {
        commit('shared/setStatusMessageParameter', {key: 'success_message', val: response.message}, {root: true});
      }

      setTimeout(() => {
        commit('resetProduct');
        payload.router.push('/product');
      }, 2000);

    }).catch(err => {
      dispatch('showValidationErrors', err);
    }).finally(() => {
      setTimeout(() => {
        commit('shared/resetStatusMessagesParameters', null, {root: true});
        // commit('resetProduct');
      }, 10000);
    });
  },
  listProducts({commit}, payload = null) {
    ProductApi.list(this.$axios, payload).then(response => {
      commit('setProductList', response.products);
    });
  },
  delete({commit, state}, id) {
    commit('shared/resetStatusMessagesParameters', null, {root: true});
    commit('shared/setStatusMessageParameter', {key: 'showLoading', val: true}, {root: true});

    ProductApi.delete(this.$axios, id).then(response => {
      commit('shared/setStatusMessageParameter', {key: 'showLoading', val: false}, {root: true});
      if(response.success) {

        let productList = {...state.product_list};
        productList.data = productList.data.filter(item => item.id !== id);

        commit('setProductList', productList);
        commit('shared/setStatusMessageParameter', {key: 'success_message', val: response.message}, {root: true});
      }
    }).catch(err => {
      console.log(err);
    }).finally(() => {
      setTimeout(() => {
        commit('shared/resetStatusMessagesParameters', null, {root: true});
      }, 5000);
    });
  },
  show({commit, state, dispatch, rootState}, id) {
    commit('shared/resetStatusMessagesParameters', null, {root: true});
    commit('shared/setStatusMessageParameter', {key: 'showLoading', val: true}, {root: true});
    commit('resetProduct');

    ProductApi.show(this.$axios, id).then(response => {
      commit('shared/setStatusMessageParameter', {key: 'showLoading', val: false}, {root: true});

      if(response.product) {

        // set product details
        for(let key in state.product) {
          if(key === 'discount' && response.product[key] == null) {
            response.product[key] = 0;
          }

          if(key === 'discount_start_date' && response.product[key] == null) {
            response.product[key] = "";
          }

          if(key === 'discount_end_date' && response.product[key] == null) {
            response.product[key] = "";
          }

          if(key === 'brand_id' && response.product[key] == null) {
            response.product[key] = "";
          }

          commit('setProduct', {key, value: response.product[key]});
        }

        // load category to retrieve features
        dispatch('category/showCategory', state.product.category_id, {root: true});

        commit('resetFeatures');

        setTimeout(() => {
          if (rootState.category.features.length > 0) {
            const features = [...rootState.category.features];

            features.map(feature => {
              let productFeatureValue = "";
              if(response.product.features && response.product.features.find(f => f.field_id == feature.id) !== undefined) {
                productFeatureValue = (response.product.features.find(f => f.field_id == feature.id)).field_value;
              } else {
                productFeatureValue = "";
              }
              commit('appendToFeatures', {
                field_id: feature.id,
                field_title: feature.field_title,
                field_type: feature.field_type,
                field_value: productFeatureValue
              });
            });
          }
        }, 200);

        // load gallery
        if(response.product.gallery) {
          commit('setGallery', response.product.gallery);
        }
      }
    }).catch(err => {
      console.log(err);
    });
  },
  update({commit, dispatch, state}, payload) {
    commit('shared/resetStatusMessagesParameters', null, {root: true});
    commit('shared/setStatusMessageParameter', {key: 'showLoading', val: true}, {root: true});

    const dataToSend = {};
    dataToSend.product = state.product;
    dataToSend.features = state.features;
    dataToSend.files = state.files;

    ProductApi.update(this.$axios, dataToSend, payload.id).then(response => {
      commit('shared/setStatusMessageParameter', {key: 'showLoading', val: false}, {root: true});
      if(response.success) {
        commit('shared/setStatusMessageParameter', {key: 'success_message', val: response.message}, {root: true});
      }

      setTimeout(() => {
        commit('resetProduct');
        payload.router.push('/product');
      }, 2000);
    }).catch(err => {
      dispatch('showValidationErrors', err);
    }).finally(() => {
      setTimeout(() => {
        commit('shared/resetStatusMessagesParameters', null, {root: true});
      }, 10000);
    });
  },
  showValidationErrors({commit}, err) {
    commit('shared/setStatusMessageParameter', {key: 'showLoading', val: false}, {root: true});
    if(err.response.data) {
      commit('shared/setStatusMessageParameter', {key: 'error_message', val: err.response.data.message}, {root: true});
      if(err.response.data.errors) {
        let errors = [];
        for(let key in err.response.data.errors) {
          errors.push(err.response.data.errors[key][0]);
        }

        commit('shared/setStatusMessageParameter', {key: 'validation_errors', val: errors}, {root: true});
      }
    }
  },
  removeImage({commit, state}, id) {
    commit('shared/resetStatusMessagesParameters', null, {root: true});
    commit('shared/setStatusMessageParameter', {key: 'showLoading', val: true}, {root: true});

    ProductApi.deleteImage(this.$axios, id).then(response => {
      commit('shared/setStatusMessageParameter', {key: 'showLoading', val: false}, {root: true});
      if(response.success) {
        commit('shared/setStatusMessageParameter', {key: 'success_message', val: response.message}, {root: true});

        const gallery = [...state.gallery];

        commit('setGallery', gallery.filter(gal => gal.id !== id));
      }
    }).catch(err => {
      console.log(err);
    }).finally(() => {
      setTimeout(() => {
        commit('shared/resetStatusMessagesParameters', null, {root: true});
      }, 10000);
    });
  }
};

 

 

Continue to Part9: Users Management

 

0 0 votes
Article Rating

What's your reaction?

Excited
0
Happy
0
Not Sure
0
Confused
0

You may also like

Subscribe
Notify of
guest

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments