DEV Community

Cover image for Create a Shopping Cart with Vuejs and Pinia
Andrew Zachary
Andrew Zachary

Posted on

Create a Shopping Cart with Vuejs and Pinia

A shopping cart is a vital component for any e-commerce website, allowing customers to easily make a purchase of products while keeping track of the total price. Additionally, features such as infinite scroll and search functionality can greatly enhance the user experience, reducing the distance between customers and products and ultimately leading to increased sales and profits.

Vuejs is my favorite framework. In this post, I would like to share with you my implementation to use it in conjunction with other libraries within the Vue.js ecosystem to build a shopping cart. By leveraging these libraries, we can easily build components and manage the state changes of the application, resulting in a more efficient and effective development process.

shopping-cart

Requirements

To follow along with me, you need to be familier with Vuejs and it's ecosystem.

Content

I will use DummyJSON, to get dummy/fake JSON data to use as placeholder in development and prototyping, without worrying about writing a backend.

๐Ÿ˜ƒ Allow me to show you.

Setting up the project

Vite is simplifying the workflow, I will use it to create a new project and install all needed libraries:

  • Pinia is a store library for Vuejs, it allows you to share a state across components/pages.
  • Vueuse a collection of essential Vuejs composition utilities.
  • Primevue is a big collection of Vuejs UI Components with top-notch quality to help you implement all your UI requirements in style.
  • Tailwindcss it helps a lot to write css quicker.
npm i --save @vueuse/core pinia primevue primeicons
Enter fullscreen mode Exit fullscreen mode

Creating the components

I will break down the Shopping-Cart into four components:

1- Cart-Toggle-Btn component that will hide/show the cart list items and give a counting summary of the total items number within the cart. It will also watch for the changes in items number and play the animate__heartBeat animation on itself to tell that a change has happened:

<script setup>

    import { ref, watch } from "vue";

    import Badge from '../Common/Badge.vue';

    const props = defineProps(['totalItems']);

    const cartRef = ref(null);

    watch(() => props.totalItems, () => {
        cartRef.value.classList.add('animate__heartBeat');
    });

    const removeTadaClass = () => {
        cartRef.value.classList.remove('animate__heartBeat');
    };

</script>
<template>

    <div id="animation-box" class="inline-block cursor-pointer animate__animated" @animationend="removeTadaClass" ref="cartRef">
        <Badge 
            :value="totalItems" 
            class="toggle-btn
            pi pi-shopping-cart 
            border-2 border-secondary rounded-full
            text-lg text-white
            p-4" 
        />
    </div>

</template>
Enter fullscreen mode Exit fullscreen mode

2- MainDialog component will display the cart list items, the total cost, and the submit order button in an overlay window:

<script setup>

    import BtnIconRounded from "../Common/BtnIconRounded.vue";

    import Tabs from "./Tabs.vue";

    defineProps(['toggleCart', 'totalCost']);

    const emits = defineEmits(['closeCart']);

</script>
<template>

    <Dialog :visible="toggleCart" header="Header" :closable="false">
        <template #header>
            <div class="w-full flex flex-col">
                <div class="w-full max-w-bd-sm mx-auto p-4 flex items-center justify-between">
                    <div class="text-md text-primary dark:text-primary-dark font-ssp font-bold">$ {{ totalCost }}</div>
                    <BtnIconRounded icon="pi pi-times" @click="emits('closeCart')" />
                </div>
            </div>
        </template>
        <Tabs />
    </Dialog>

</template>
Enter fullscreen mode Exit fullscreen mode

3- List component will present the cart items and enable the user to add/remove units of a particular product within the cart:

<script setup>

    import { ref } from "vue";

    import useCartStore from "../../stores/cart";
    import { useTranslate } from "../../composables/useTranslate";
    import { useSimpleBar } from "../../composables/useSimpleScroll";
    import BtnIconRounded from "../Common/BtnIconRounded.vue";
    import Btn from "../Common/Btn.vue";

    const cartStore = useCartStore();
    const { doTranslate } = useTranslate();
    const cartList = ref(null);
    const emits = defineEmits(['changeTab']);

    useSimpleBar({elementRef: cartList});

</script>
<template>
    <div id="list-box" ref="cartList" class="h-full max-w-bd-sm mx-auto relative overflow-auto">    
        <div
            v-if="cartStore.items.length > 0" 
            class="w-full max-w-bd-sm mx-auto 
            pb-8 px-4 fixed z-10 
            bg-white dark:bg-primary-bg-dark"
        >
            <Btn :label="doTranslate('cart.submit')" icon="pi pi-shopping-cart" size="sm" @click="emits('changeTab', 'order')" />
        </div>
        <ul class="w-full px-2 pt-20">
            <li v-for="item of cartStore.items" :key="item.id" class="mt-8 text-secondary">
                <div class="text-sm text-secondary dark:text-white font-mont font-bold">{{ item.title }}</div>
                <div class="text-xs text-secondary dark:text-white font-ssp flex">
                    <span>{{ item.count }}</span>
                    <span class="px-2">{{ doTranslate('cart.for') }}</span>
                    <span class="px-2">$</span>
                    <span class="font-bold">{{ item.price * item.count }}</span>
                </div>
                <div class="flex">
                    <div class="p-4"><BtnIconRounded icon="pi pi-plus" @click="cartStore.addItem(item)" /></div>
                    <div class="p-4"><BtnIconRounded icon="pi pi-minus" @click="cartStore.removeItem(item)"/></div>
                </div>
            </li>
        </ul>
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

4- The Container component will wrap all the children components and position the Cart-Toggle-Btn component to be sticky on the page:

<script setup>

    import { ref } from "vue";

    import useCartStore from "../../stores/cart";
    import ToggleBtn from "./ToggleBtn.vue";
    import MainDialog from "./MainDialog.vue";

    const cartStore = useCartStore();
    const toggleCart = ref(false);

</script>

<template>

    <div class="sticky left-100 bottom-0
        z-10
        p-4 pb-20"
        @click="toggleCart = true"
    >
        <ToggleBtn :total-items="cartStore.totalItems" />
        <MainDialog :total-cost="cartStore.totalCost" :toggleCart="toggleCart" @closeCart="toggleCart = false" />
    </div>


</template>
Enter fullscreen mode Exit fullscreen mode

Creating the necessary stores

I need two stores to manage the state of both the cart and the products list which the user selects from it:

1- Products-Store will store the items received from the API and push them into an array, paginate over that array, and check if the API end-point has more items:

import { ref } from "vue";
import { defineStore } from "pinia";

export default defineStore('products', () => {

    const products = ref({
        items: [],
        currentPageNum: 0,
        limitPerPage: 30,
        hasMore: true
    });

    const productsReceived = (items) => {

        if(items.length === 0) return products.value.hasMore = false;

        products.value.items = [...products.value.items, ...items];
        products.value.currentPageNum += 1;

    };

    const resetProducts = () => {
        products.value = {
            items: [],
            currentPageNum: 0,
            limitPerPage: 30,
            hasMore: true
        }
    }

    return {
        products,
        productsReceived,
        resetProducts
    }
});
Enter fullscreen mode Exit fullscreen mode

2- Cart-Store will be responsible for adding a new item to the cart or removing it, calculating the total items number and the total cost:

import { ref } from "vue";
import { defineStore } from "pinia";

export default defineStore('cart', () => {

    const items = ref([]);
    const totalItems = ref(0);
    const totalCost = ref(0);

    const addItem = (item) => {
        let targetItem = items.value.filter( currItem => currItem.id === item.id )[0];

        if(targetItem) targetItem.count += 1;
        else items.value = [...items.value, {...item, count: 1}];

        totalItems.value += 1;
        totalCost.value += item.price;
    };

    const removeItem = (item) => {
        let targetItem = items.value.filter( currItem => currItem.id === item.id )[0];

        if(targetItem.count === 1) items.value = items.value.filter( currItem => currItem.id !== item.id );
        else targetItem.count -= 1;

        totalItems.value -= 1;
        totalCost.value -= item.price;
    };

    return {
        items,
        addItem,
        removeItem,
        totalItems,
        totalCost
    };

});
Enter fullscreen mode Exit fullscreen mode

Create infinite scroll composable and SearchInput component

The last two pieces of the puzzle:

1- To add an Infinite-Scroll feature to the products list to load more items when the user scrolls down and reaches its bottom.

2- The SearchInput component will help the user to search the products list and quickly find the desired product.

Also, I want the style of the Scrollbar to be the same on all devices and browsers, I will achieve that by using simplebar lib wrapped inside a Vuejs composable.

Ok let's add some cdns for the simplebar lib:

<script src="https://cdn.jsdelivr.net/npm/simplebar@latest/dist/simplebar.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/simplebar@latest/dist/simplebar.css" />
Enter fullscreen mode Exit fullscreen mode

Here is the composable:

import { onMounted, onBeforeUnmount } from "vue";

export function useSimpleBar({elementRef, callback = null}) {

    let scrollerObj = null;
    const onScrollEvent = (e) => {
        if ((Math.floor(e.target.scrollTop) + Math.floor(e.target.clientHeight) + 150) > Math.floor(e.target.scrollHeight)) {
            callback();
        }
    };

    onMounted(() => {

        scrollerObj = new SimpleBar(elementRef.value);

        if(callback) {
            scrollerObj.getScrollElement().addEventListener('scroll', onScrollEvent);
        }

    });

    onBeforeUnmount(() => {

        scrollerObj.unMount();

        if(callback) {
            scrollerObj.getScrollElement().removeEventListener('scroll', onScrollEvent);
        }

    });

    return null;
}
Enter fullscreen mode Exit fullscreen mode

Offering the ability to search the products list will help the user to browse the products list and choose the needed ones, useDebounceFn is a useful composable from vueuse lib that can help in reducing the number of API calls to the server:

<script setup>
    import { useDebounceFn } from '@vueuse/core';
    import { useTranslate } from "../composables/useTranslate";

    const emit = defineEmits(['searching']);

    const { doTranslate } = useTranslate();
    const debouncedSearch = useDebounceFn((value) => {
        emit('searching', value);
    }, 1000);
</script>

<template>
    <input class="p-8 text-secondary
    placeholder:text-secondary placeholder:dark:text-white"
    :placeholder="doTranslate('searching.placeholder')"
    @input="debouncedSearch($event.target.value)" />
</template>
Enter fullscreen mode Exit fullscreen mode

Conclusion

Full Code

Working Example

Breaking the task into smaller tasks definitely will help you to solve the problem and complete the task. Vuejs has a rich ecosystem that offers all the necessary tools to build any application.

Your feedback is greatly appreciated, thank you for reading.

Top comments (0)