How to create a real-time Bidding App using Strapi v4, Vue and Socket IO

How to create a real-time Bidding App using Strapi v4, Vue and Socket IO

In this tutorial, we will be building a real-time bidding application using Vue, Socket IO and Strapi.

Author: Ian Nalyanya

A bid is an offer to purchase an asset at a specific price. Prices are mainly determined by how much an individual is willing to pay to own a specific asset. The bidding process is routinely seen in auctions. Recently, the process has been digitized and introduced to various eCommerce sites. For instance, eBay, a multinational e-commerce company based has an auction format product listing which consists of the starting price, listing end date and bids made for that listing. Auctions usually accept bids for a specific amount of time hence the significance of the listing end date.

Prerequisites

  • NodeJS installation
  • Basic Vue Knowledge
  • An Integrated Development Environment, I use Vscode but you are free to use others.
  • Prior Strapi knowledge is helpful but not required - Learn the basics of Strapi v4.
  • Basic JSON Web Token Authentication Knowledge.

An Introduction to Strapi, Socket IO and Vue

Strapi is a headless Content Management System(CMS). This means that it handles all the logic needed to transform and store data in the database without having a user interface. Strapi exposes API endpoints that can be used to manipulate data stored in a database. Furthermore, it handles authentication and provides numerous methods to plugin third-party authentication systems like Google and Facebook Auth. Such features shorten project development timelines enabling developers to focus on the User Interfaces instead of the back-end logic and process.

Socket IO is an event-driven JavaScript module that builds on the WebSocket protocol to provide bi-directional event-driven communication. It is highly scalable and can be used on all major platforms. By hooking Socket IO on the Strapi server instance, we’ll be able to make all data management functions real-time which is essential in an online bidding system.

Lastly, we will be using the Vue framework to consume data from the Strapi server instance. Vue supports two-way binding which enables related UI components to be updated in real-time without compromising on performance while rending DOM objects.

Initializing a Strapi Project

We are going to start by setting up our project folder which will consist of the Strapi instance(back-end) and the Vue application(front-end). Both of which require node installation.

  1. Run the following command to create a scaffold of the Strapi server:
    npx create-strapi-app@latest backend --quickstart

Strapi v4.2.0 is the latest stable version at the time of writing this article.

The command will create the backbone of our system, install necessary dependencies and initialize an SQLite Database. You can connect your preferred database using the following guide. Once every dependency has been installed, your default browser will open and render the admin registration page for Strapi.

  1. Create your administrator account then you’ll be welcomed with the page below.

Strapi Admin Welcome Screen

Creating Collections

A collection is a data structure that defines how our data will be represented in the database. We will use the content-type builder plugin to create the necessary fields for each collection.

Product Collection

Firstly, build a product collection by following the steps below.

  1. Click on Content-type Builder under plugins in the side navigation bar.
  2. Click on Create new collection type.
  3. Type Product for the Display name and click Continue.
  4. Click the Text field button.
  5. Type name in the Name field.
  6. Click on Add another field.
  7. Repeat the above steps for the following fields with the corresponding field types.
    • auction_end - Datetime with datetime(ex: 01/01/2022 00:00AM) type
    • price - Number with big integer format
    • image - Media (Multiple Media)
    • weight - Number with decimal format
    • bid_price - Number with big integer format
    • description - Rich Text
    • auction_start - Datetime with datetime(ex: 01/01/2022 00:00AM) type
    • available - Boolean
  8. Click the Save button and wait for the changes to be applied

We are going to use the auction_end field to create a countdown by getting the difference between the server time and the value of that field. We’ve selected multiple media on the image field because we will be using bootstrap’s carousel code block to showcase the different images of the product. Finally, the price field will be the base price of a product. The bid_price will be the sum of a bid value and the current bid_price. Initially, both the price and bid_price are the same but once bids are created the bid_price should increase in real-time.

Product Collection Configuration

Account Collection

We are going to repeat the above processes but in the account context. Use the steps below to guide you through.

  1. Click on Content-type Builder under plugins in the side navigation bar.
  2. Click on Create new collection type.
  3. Type Account for the Display Name and click Continue.
  4. Click the Number field button.
  5. Type balance in the Name field and select the big integer Number format.
  6. Click on Add another field.
  7. Click Relation
    • On the dialog containing Account as the title, enter the field name as user
    • On the other dialog select **User(from: user-permissions)** and type account on the field name input
    • Select the option that illustrates two circles joined by one line as shown below One-to-one relationship between an account and a User

A relation is used to describe two database entities that are related in our context, the account table will be related to the user table. The relation is a one-to-one relationship meaning that each user will have only one account and an account can only be linked to one specific user at a time.

The balance field will be used to save the number of funds needed to purchase a product. A user will only be able to bid on products whose bid_price is less than their current account balance. In simpler terms, you can only bid on products you can afford to purchase on the system.

Account Collection Configuration

Bid Collection

  1. Click on Content-type Builder under plugins in the side navigation bar.
  2. Click on Create new collection type.
  3. Type Bid for the Display name and click Continue.
  4. Click the Number field button.
  5. Type value in the Name field.
  6. Click on Add another field.
  7. Click Relation
    • On the dialog containing Bid as the title, enter the field name as account
    • On the other dialog select **Account** and type bids on the field name input
    • Select the option that illustrates one circle on the right joined by many circles on the left as shown below One-to-Many relationship between an account and bids

Unlike the User-Account relation we had created, the Bid-Account relation is a one-to-many relationship. This means that an account can have many bids and a bid can only be associated with one account at a time.

  1. Click on Add another field
  2. Click Relation
    • On the dialog containing Bid as the title, enter the field name as account
    • On the other dialog select **Product** and type bids on the field name input
    • Select the option that illustrates one circle on the right joined by many circles on the left as shown below

One-to-Many relationship between a product and bids

The Bid-Product relation ensures that each bid is associated with a product. This enables us to track the bid_price of a product. The sum of all bid value and a product’s price is equal to that product’s bid_price.

Bid Collection Configuration

Extending Collection Services

After creating and saving the collection types, Strapi modifies our project directory structure. Each collection type has its folder in the /src/api folder containing route, controller, content-types and services directories. We will be modifying the contents in the services directory for each collection to manipulate the data associated with that specific collection we had created.

Collection Type Project Directory

What are collection services?

Services are reusable functions that manipulate data associated with a specific collection. They can be generated using Strapi’s interactive CLI or manually by creating a JavaScript file in the collection type’s project directory ./src/api/[collection-type-name]/services/

Account Collection Service

We are going to create two functions that will help us query and add records associated with accounts.

    ./src/api/account/services/account.js

    'use strict';
    /**
     * account service.
     */
    const { createCoreService } = require('@strapi/strapi').factories;
    module.exports = createCoreService('api::account.account', ({ strapi }) => ({
        newUser(user_id) {
            return strapi.service('api::account.account').create({ 
            data: { 
                  balance: 0, user: user_id 
                }
            });
        },
        getUserAccount(user_id) {
            return strapi.db.query('api::account.account').findOne({
                where: { user: user_id },
            })
        }
    }));
  • The **newUser** function enables us to associate a newly registered user to an account. It takes one parameter which is the primary key from the User table. The parameter user_id is used to create an account for that specific user. Each account will have an initial balance value of 0.
  • The **getUserAccount** function enables us to fetch an account associated with a specific user. It also takes user_id as a parameter. We then use a function from Strapi’s Query Engine API to fetch the record. We are specifically using the findOne function because each account is linked to one user.

Product Collection Service

We’ll create two functions, one to load bids associated with a specific product and the other one to update the bid price.

    ./src/api/product/services/product.js

    'use strict';
    /**
     * product service.
     */
    const { createCoreService } = require('@strapi/strapi').factories;
    module.exports = createCoreService('api::product.product', ({ strapi }) => ({
        loadBids(id) {
            return strapi.entityService.findOne('api::product.product', id, {
                fields: "*",
                populate: {
                    bids: {
                        limit: 5,
                        sort: 'createdAt:desc',
                        populate: {
                            account: {
                                fields: ['id'],
                                populate: {
                                    user: {
                                        fields: ['username']
                                    }
                                }
                            }
                        }
                    },
                    image: true
                },
            });
        },
        async findAndUpdateBidPrice(found, price) {
            return strapi.entityService.update('api::product.product', found.id, {
                data: {
                    bid_price: parseInt(found.bid_price) + parseInt(price)
                },
            });
        }
    }));
  • The **loadBids** function takes the product id as a parameter which is then used to fetch a specific product. We must explicitly specify which relations we want to load this ensures that we only request attributes that we are sure will be used by our frontend application. We loaded the product image, bids and nested relations under the bid relation so that we can view which user made a specific bid. We also sorted the bids according to the createdAt field so that the most recent bids will always be displayed first on our Vue application.
  • The **findAndUpdateBidPrice** takes two parameters. The first parameter is the product while the second parameter is the bid price. We are passing a product object as a param because we need to access some fields contained in the object. Before saving the new bid_price, we must ensure that the value provided is an integer. We use the parseInt(arg0) to accomplish this.

Extending User Permission Plugin

Plugins are the backbone of the majority of the features Strapi supports. They can be tweaked and extended to add more functionality or custom logic depending on an application’s objective. By default Strapi ships with the following plugins:

  1. Content Manager - Used to create, view, edit and delete records belonging to a specific collection.
  2. Content Type Builder - Used to manipulate collections. We used this earlier to create our collections.
  3. Email - Handles all email delivery actions.
  4. Media Library - Handles actions related to file uploads. We’ll use it to upload product images
  5. Internationalization - This plugin is responsible for converting content to other specified languages.
  6. User Roles and Permissions - Provides a way to authenticate to the Strapi server using JSON Web Tokens. It also defined user types and actions they are allowed to do. So far we used the admin user type to interact with content.

User Roles and Permission Plugin Override

Since the user role and permission plugin is preinstalled on every Strapi application we need to access its source code from the node_modules folder. The specific folder we are interested in is ./node_modules/@strapi/plugin-users-permissions and we’ll copy the callback and register functions from ./node_modules/@strapi/plugin-users-permissions/server/controllers/auth.js. To extend and override those functions from that plugin we need to create strapi-server.js in ./src/extensions/users-permissions

The functions we copied are a lot to consume but basically, the callback function is called every time a user wants to log in. If the credentials are valid the server responds with the JWT token and some basic user information such as the username, email and user_id. In the callback function, just before the user details are sent, we call the getUserAccount function we had created earlier. We append the account balance to the response so that we can access it later on our Vue application.

    const user = await getService('providers').connect(provider, ctx.query);
    //Import the account service to fetch account details
    const account = await strapi.service('api::account.account').getUserAccount(user.id);
    ctx.send({
      jwt: getService('jwt').issue({ id: user.id }),
      user: { 
      ...await sanitizeUser(user, ctx), 
      balance: account.balance, account: account.id },
    });

The register function is called each time someone wants to create an account on the system. Once the details provided are valid, the server responds with the JWT token and basic user details. Just before the response is sent, we call the newUser function. This ensures that every new user has an account immediately they sign up.

    if (!settings.email_confirmation) {
      params.confirmed = true;
    }
    const user = await getService('user').add(params);
    const account = await strapi.service('api::account.account').newUser(user.id);
    const sanitizedUser = await sanitizeUser(user, ctx);
    if (settings.email_confirmation) {
      try {
          await getService('user').sendConfirmationEmail(sanitizedUser);
      } 
      catch (err) {
        throw new ApplicationError(err.message);
      }
      return ctx.send({ 
        user: { 
          ...sanitizedUser, 
          balance: account.balance, 
          account: account.id 
        } });
    }
    const jwt = getService('jwt').issue(_.pick(user, ['id']));
    return ctx.send({
        jwt,
        user: { ...sanitizedUser, balance: account.balance, account: account.id },
    });

The full `./src/extensions/users-permissions/strapi-server.js` can be found on the [backend repo](https://github.com/i1d9/strapi-bids-backend/blob/master/src/extensions/users-permissions/strapi-server.js).


> The register function is called on POST requests made at /api/auth/local/register
> The callback function is called on POST requests made at /api/auth/local/
# Socket IO Initialization

To use the Socket IO module, we need to install it first. Run the command below to install it in the projects `package.json` file.

```bash
    npm install socket.io
    # OR
    yarn add socket.io

The latest version of socket.io was 4.5.1 when I was writing this article

The socket needs to be instantiated before the server starts since Socket IO listens on the same address and port number. Open ./src/index.js, the file contains functions that run before the Strapi application is started. We are going to add our code within the bootstrap function block. We specify the Cross-Origin Resource Sharing(CORS) object so that the server knows where the request has been made from. The address http://localhost:8080 is the default endpoint for Vue applications in the development environment.

    bootstrap({ strapi }) {

    let interval;
    var io = require('socket.io')(strapi.server.httpServer, {
          cors: {
            origin: "http://localhost:8080",
            methods: ["GET", "POST"]
          }
    });

    io.on('connection', function (socket) {
      if (interval) clearInterval(interval);
      console.log('User connected');

      interval = setInterval(() => io.emit('serverTime', { time: new Date().getTime() }) , 1000);

      //Load a Product's Bids
     socket.on('loadBids', async (data) => {
        let params = data;
        try {
        let data = await strapi.service('api::product.product').loadBids(params.id);
        io.emit("loadBids", data);
        } catch (error) {
          console.log(error);
        }
     });

      socket.on('disconnect', () => {
            console.log('user disconnected');
            clearInterval(interval);
          });
      });

      //Make the socket global
      strapi.io = io
    }

Socket IO is an event-driven module therefore we will be listening for events at specified event names. For instance, the loadBids will be triggered to respond with bids when a client sends a payload containing the product_id. The response will be generated by the product collection service function we had created earlier.

Within the connection event block, we have an interval function that sends the server time to the client after every second(1000 ms). The server time will be used to feed the count-down function on the client-side. Once a socket connection is terminated the disconnect event is triggered and clears the interval.

Socket Authentication

Since we are using Strapi’s authentication system, we could verify JWT tokens before a connection is made. We will use the users-permission plugin once again to perform the verification. Invalid tokens will be rejected and the connection will not be accepted. We’ll add the authentication login before the connection event.

    let interval;
    var io = require('socket.io')(strapi.server.httpServer, {
      cors: {
            origin: "http://localhost:8080",
            methods: ["GET", "POST"]
          }
    });

    io.use(async (socket, next) => {
      try {
        //Socket Authentication
        let result = await strapi.plugins[
              'users-permissions'
            ].services.jwt.verify(socket.handshake.query.token);
            //Save the User ID to the socket connection
            socket.user = result.id;
            next();
          } catch (error) {
            console.log(error)
          }
    }).on('connection', function (socket) {});

We saved the user id to the socket connection so that we can reference which user is creating a bid. Add the block below to handle the new bid logic.

    socket.on('makeBid', async (data) => {
     let params = data;
     try {
     //Get a specific product
     let found = await strapi.entityService.findOne('api::product.product', params.product, { fields: "bid_price" });

     //Load the user's account
     const account = await strapi.service('api::account.account').getUserAccount(socket.user);

      //Check whether user has enough more to make the bid
      if (parseInt(account.balance) >= parseInt(found.bid_price)) {
        //Make new Bid
        await strapi.service('api::bid.bid').makeBid({ ...params, account: account.id });
      //Update the product's bid price
      let product = await strapi.service('api::product.product').findAndUpdateBidPrice(found, params.bidValue);

      let updatedProduct = await strapi.service('api::product.product').loadBids(product.id);

      //Send the bids including the new one
      io.emit("loadBids", updatedProduct);
        } else {
          console.log("Balance Is low")
        }

      } catch (error) {
      console.log(error);
      }
    });

The makeBid event checks the user has enough funds in their account before a bid is created. We then call the service functions from the account, product and bid collections.

Creating New User Roles

Finally, we need to make the product collection available to authenticated users only. This will ensure that products are loaded when HTTP GET requests are made to the http://localhost:1337/api/products endpoint.

  1. On the side navigation menu in the administrator panel, click on Settings
  2. Click on Roles under the User & Permissions plugin section
  3. Click Add new role
  4. Type Bidder in the name field
  5. Type Can create bids and view product listings in the description field.
  6. In the Permissions section click on Products to display available options
  7. Select find and findOne then click the save button Creating a new user role

  8. Click on the Advanced settings button in the user & permission plugin then select Bidder as the default role for an authenticated user. This ensures that every new user is automatically a bidder and they can view the product listing if their jwt token is appended to a GET request to /api/products and /api/products/:id

Initializing the Vue Project

We are going to set up our front-end Vue application generator using the following command.

    npm install -g @vue/cli
    # OR
    yarn global add @vue/cli

Once the CLI has been installed successfully, run the following command to create the project.

    vue create frontend

Installation of auxiliary dependencies

Axios

We will be making HTTP requests with Axios HTTP Client. Install it using the following command

    npm install axios
    # OR
    yarn add axios

Once authenticated, we will append our jwt Token to every request sent to the back-end using Axios. If the token is successfully verified, the server will respond with the resource we requested.

Socket IO Client

We will be sending and receiving data using Socket IO’s front-end library. Install it using the following command.

    npm install socket.io-client
    # OR
    yarn add socket.io-client

We will use our jwt Token to authenticate our socket connections. We will use the same event names used in the server setup to enable us to receive and send data.

Vue Router and UI components

This library will be used to map our UI components to routes. Install it using the command below.

    npm install vue-router
    # OR
    yarn add vue-router

We’ll create five components, four of which will be mapped to routes. Feel free to use any CSS framework to style up your components. I’ll be using bootstrap 5 which you could install with the command below.

    npm install bootstrap
    # OR 
    yarn add bootstrap

We’ll have the following project structure, grouping related components together.

Project Structure

State Persistence

Each Vue component has its state however we will need some state values to be accessed globally. In our case, we’ll need to access the jwt token from every component to authenticate the socket connections and every request we send to the Strapi back-end. We’ll use vuex which is a state management library for Vue.js applications and vuex-persistedstate, which is a vuex plugin that allows us to store the global state to the browser's local storage instance. To get started create store.js inside the src directory.

    ./src/store.js

    import Vuex from 'vuex';
    import axios from 'axios';
    import createPersistedState from "vuex-persistedstate";
    const dataStore = {
        state() {
            return {
                auth: null//The variable we want to access globally
            }
        },
        mutations: {
            setUser(state, user) {
                state.auth = user
            },
            logOut(state) {
                state.auth = null
            }
        },
        getters: {
            getUser(state) {
                return state.auth;
            }, 
            isAuthenticated: state => !!state.auth,
        },
        actions: {
            //Register New Users.
            async Register({ commit }, form) {
                const json = JSON.stringify(form);
                const { data } = await axios
                    .post('http://localhost:1337/api/auth/local/register', json, {
                        headers: {
                            'Content-Type': 'application/json'
                        }
                    });
                //Populate the Auth Object
                await commit('setUser', { ...data.user, token: data.jwt });
            }, 
            //Authenticate a returning user.
            async LogIn({ commit }, form) {

                const json = JSON.stringify(form);
                const { data } = await axios
                    .post('http://localhost:1337/api/auth/local/', json, {
                        headers: {
                            'Content-Type': 'application/json'
                        }
                    });
                //Populate the Auth Object
                await commit('setUser', { ...data.user, token: data.jwt });
            }, 
            async LogOut({ commit }) {
                let user = null
                commit('logOut', user);
            }
        }
    }
    export default new Vuex.Store({
        modules: {
            dataStore
        },
        plugins: [createPersistedState()]
    })

The dataStore object contains:

  1. Mutations - These are functions that modify our state variables. They are invoked by the store’s commit function.
  2. Actions - Functions that can be used to invoke commits asynchronously. We defined the register and login functions which will send user credentials to the server for validation. Before sending the request we have to make sure that the payload being sent is a JSON string. The strapi server will respond with the user details and a jwt token which will be saved by the setUser mutation invoked by the store’s commit function
  3. State - A function that defines all the variables we want to save and persist.
  4. Getters - Exposes the state variables for consumption from the stored state. We’ll be using those functions to check whether the user is authenticated before rendering a component.

UI Component Mapping

We’ll use the vue-router library to render components that match specific routes. We’ll also add some logic before routing to make sure that only authenticated users can access certain UI components. We call the store getter function isAuthenticated to achieve that.

****
    ./src/main.js
    import { createApp, h } from 'vue'
    //Bootstrap CSS Framework
    import "bootstrap/dist/css/bootstrap.min.css"
    import App from './App.vue'
    import { createRouter, createWebHashHistory } from "vue-router";
    //Import the state manager
    import store from './store';
    //Product Components
    import ProductList from "./components/Product/List.vue";
    import ProductDetail from "./components/Product/Detail.vue";
    //Authentication Components
    import LoginPage from "./components/Auth/Login.vue";
    import RegisterPage from "./components/Auth/Register.vue";

    //Mapping Routes to Components
    const routes = [
        { path: "/", component: ProductList, meta: { requiresAuth: true },},
        { path: "/:id", component: ProductDetail, meta: { requiresAuth: true },},
        { path: "/login", component: LoginPage, meta: { requiresAuth: false },},
        { path: "/register", component: RegisterPage, meta: { requiresAuth: false }},
    ];
    const router = createRouter({
        history: createWebHashHistory(),
        routes,
    });
    router.beforeEach((to, from) => {
      if (to.meta.requiresAuth && !store.getters.isAuthenticated) {
            return {
                path: '/login',
                // save the location we were at to come back later
                query: { redirect: to.fullPath },
            }
        }
    });
    const app = createApp({
        render: () => h(App),
    });
    //Add the router to the Application
    app.use(router);
    //Add the state manager to the Vue Application.
    app.use(store);
    app.mount('#app');
    //Bootstrap JS Helpers
    import "bootstrap/dist/js/bootstrap.js"

Finally, to make our mapped components render, we need to modify Vue’s main UI component which is the parent component for all the pages we are going to create. The route-view tag will be responsible for loading components based on the route. The NavBar component will be visible across all components.

    ./src/App.vue
    <template>
      <NavBar />
      <router-view :key="$route.fullPath"></router-view>
    </template>
    <script>
    import NavBar from "./components/Nav.vue";
    export default {
      name: 'App',
      components: {
        NavBar
      }
    }
    </script>

Login Component

The Login Page is only rendered when an HTTP GET request is made on http://localhost:8080/login. We use a simple HTML form to capture the user’s email and password and then pass it to the LogIn action we had created. The user will be redirected to http://localhost:8080/ if the credentials are correct.

    ./src/components/Auth/Login.vue
    <template>
    <div class="container my-5">

    <form @submit.prevent="submit" class="d-flex flex-column">
    <div class="mb-3">
    <input type="email" class="form-control" placeholder="Email Address" name="email" v-model="form.email" />
    </div>

    <div class="mb-3">
    <input class="form-control" type="password" name="password" v-model="form.password" placeholder="Password" />
    </div>

    <button type="submit" class="btn btn-success m-1">Login</button>

    <router-link to="/register" class="btn btn-outline-primary m-1">Register</router-link>

    </form>
    <p v-if="showError" id="error">Invalid Email/Password</p>
    </div>
    </template>
    <script>
    import { mapActions } from "vuex";
    export default {
        name: " LoginPage",
        data() {
            return {
                form: {
                    email: "",
                    password: "",
                },
                showError: false
            }
        },
        methods: {
            ...mapActions(["LogIn"]),
            async submit() {
                try {
                    await this.LogIn({
                        identifier: this.form.email,
                        password: this.form.password
                    });
                    this.$router.push(this.$route.query.redirect)
                    this.showError = false
                } catch (error) {
                    this.showError = true
                }
            }
        }
    }
    </script>

Rendered Login Component

We added some bootstrap CSS styles to make the page mobile responsive. The Register Button directs the user to the registration page at http://localhost:8080/register

Register Component

The Register page is used to create new accounts. By default, all users are [Bidders](https://www.dropbox.com/scl/fi/jrha49e6tg0hjx1xx4csp/How-to-create-a-real-time-Bidding-App-using-Strapi-v4-Vue-and-Socket-IO.paper?dl=0&rlkey=di2crkfkq9ckjsafeadke6obw#:uid=540338709510983306879551&h2=Creating-new-user-roles) and they all have a monetary account. If the server successfully creates the authentication account the user is automatically assigned a monetary account.

    ./src/components/Auth/Register.vue
    <template>
    <div class="container my-5">
    <form @submit.prevent="submit" class="d-flex flex-column">
    <div class="mb-3">
    <input type="text" class="form-control" placeholder="Username" name="username" v-model="form.username" />
    </div>

    <div class="mb-3">
    <input type="email" class="form-control" placeholder="Email Address" name="email"
    v-model="form.email" />
    </div>

    <div class="mb-3">
    <input type="password" class="form-control" name="password" v-model="form.password" placeholder="Password" />
    </div>

    <button type="submit" class="btn btn-success m-1">Create Account</button>
    <router-link to="/login" class="btn btn-outline-primary m-1">Login</router-link>

    </form>
    <p v-if="showError" id="error">
    Could not create an account with the details provided
    </p>

    </div>
    </template>
    <script>
    import { mapActions } from "vuex";
    export default {
        name: " RegisterPage",
        data() {
            return {
                form: {
                    username: "",
                    email: "",
                    password: "",
                },
                showError: false
            }
        },
        methods: {
            ...mapActions(["Register"]),
            async submit() {
                try {
                    await this.Register({
                        username: this.form.username,
                        password: this.form.password,
                        email: this.form.email,
                    });
                    this.$router.push("/");
                    this.showError = false
                } catch (error) {
                    this.showError = true
                }
            }
        }
    }
    </script>

Rendered Registered Component

Product Card Component

This component formats the product details within a Bootstrap-styled. The details are passed into the components via props. Furthermore the component displays the time left till the auction ends(auction_end field that we defined in the product collection type). Once the component has been created we initiated an interval function that computes the countdown values based on the serverTime prop whose value is fetched in real time from the socket io instance. This component will be rendered for all available products in the Listing component.

    ./src/components/Product/Card.vue
    <template>
    <div class="card m-3 p-3 position-relative">
    <img :src="'http://localhost:1337' + this.product.image.data[0].attributes.formats.medium.url" class="card-img-top" alt={{content.name}} />

    <div class="card-body">
    <router-link :to="'/' + this.id">{{ content.name }}</router-link>
    <p>KES {{ content.price }}</p>
    <h6 class="card-subtitle mb-2 text-muted">{{ countDownValue }} </h6>
    </div>
    <div class="position-absolute top-0 end-0 p-1 m-2 btn-outline-danger">
      Live
    </div>
    </div>
    </template>
    <script>
    export default {
      name: "CardComponent",
      props: ['product', 'serverTime', 'id'],
      data() {
        return {
                content: this.product,
                countDownInterval: null,
                countDownValue: ''
        }
      },
      methods: {
        countDown() {
         // Get today's date and time
         var now = this.serverTime;
         // Find the distance between now and the count down date
         var distance = new Date(this.product.auction_end) - now;
         // Time calculations for days, hours, minutes and seconds
         var days = Math.floor(distance / (1000 * 60 * 60 * 24));
         var hours = Math.floor((distance%(1000 *60 * 60 * 24))/(1000 * 60 * 60));
         var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
         var seconds = Math.floor((distance % (1000 * 60)) / 1000);
        this.countDownValue=`${days} Days ${hours} hrs ${minutes} minutes ${seconds} seconds`;
            }
        },
        created() {
            this.countDownInterval = setInterval(() => this.countDown() , 1000);
        }
    }
    </script>

Listing Component

This component fetches the products from the Strapi server via a normal HTTP GET request appended with a jwt Token in the header. We specified the client’s address(http://localhost:8080) on the server to prevent CORS errors. Once the products have been loaded we use the Socket IO instance to get the server time which will be used to compute the countdown of each product. The server time and product details will be passed as props to the card component.

    ./src/components/Product/List.vue
    <template>
        <div class="container p-2">
            <div class="row">
                <div class="col-lg-3 col-md-4" v-for="product in products" :key="product.id">
                    <Card :product="product.attributes" :serverTime="serverTime" :id="product.id" />
                </div>
            </div>
        </div>
    </template>
    <script>
    import axios from 'axios';
    import socketIOClient from "socket.io-client";
    import Card from './Card.vue';
    export default {
        name: "ProductList",
        data() {
            return {
                products: [],
                socket: socketIOClient("http://localhost:1337", {
                    query: {
                        token: this.$store.getters.getUser.token
                    }
                }),
                serverTime: null,
            };
        },
        methods: {
         async getproducts() {
         try {
          const response = await axios.get("http://localhost:1337/api/products?populate=image&&name",{
                headers: {
                    'Authorization': `Bearer ${this.$store.getters.getUser.token}`
                    }
                    });
                    this.products = response.data.data;
                }
                catch (error) {
                    console.log(error);
                }
            }
        },
        created() {
            this.getproducts();
            this.socket.on("serverTime", (data) => { 
                this.serverTime = data.time
            });
        },
        components: { Card }
    }
    </script>

Rendered Product Listings

Feel free to add products from the admin dashboard. Play around with the auction_end field to see how the countdown changes.

Product Detail Component

This component loads the bids through an authenticated socket connection. It also captures a user’s bid value and sends it to the server. The bid is then broadcasted to all clients viewing the same product. We also use the server time to compute the countdown time.

    <template>
    <div class="container mt-4 m-md-3 m-lg-3 ">
    <div class="row">
    <div class="col-md-7 col-lg-7">
    <div id="carouselExampleSlidesOnly" class="carousel slide" data-bs-ride="carousel">
    <div class="carousel-inner">
    <div v-for="image in this.product.image" :key="image.id">
    <img :src="'http://localhost:1337' + (image.formats.medium.url)" class="card-img-top" alt={{content.name}} /></div>
    <div class="carousel-item">
    <img src="" class="d-block w-100" alt=""></div>
    <div class="carousel-item"><img src="" class="d-block w-100" alt="">
    </div></div></div>
    <p>{{ this.product.description }}</p></div>
    <div class="col-md-5 col-lg-5"> Time left: {{ countDown(this.serverTime) }}
    <div class="card m-2 p-3">
    <p>Current Price: KES {{ this.product.bid_price }} </p>
    <div class="overflow-auto" style="height: 10rem;">
      <div v-if="this.bids.length > 0">
        <div v-for="bid in bids" :key="bid.id">
          <div class="border p-3 m-2">
          <p>{{ bid.account.user.username }}</p>
          <p>KES {{ bid.value }}</p>
          </div>
        </div>
      </div>
      <div class="card text-center m-2 p-3" v-else>
      <span>No Bids available</span>
      </div>
    </div>
    </div>
    <div class="m-2 d-flex flex-column">
    <input type="number" v-model="bidValue" placeholder="Bid Value" class="form-control" min="1" />
    <button type="button" @click="makeBid" class="btn btn-outline-warning">Bid</button>
    </div>
    </div>
    </div>
    </div>
    </template>
    <script>
    import socketIOClient from "socket.io-client";
    export default {
        name: "ProductDetail",
        data() {
            return {
                bidValue: 1,
                product: {
                },
                bids: [],
                socket: socketIOClient("http://localhost:1337", {
                    query: {
                        token: this.$store.getters.getUser.token
                    }
                }),
                serverTime: new Date(),
                countDownInterval: null,
                countDownValue: ''
            }
        },
        methods: {
         countDown(now) {
         // Find the distance between now and the count down date
         var distance = new Date(this.product.auction_end) - now;
         // Time calculations for days, hours, minutes and seconds
         var days = Math.floor(distance / (1000 * 60 * 60 * 24));
      var hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
        var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
        var seconds = Math.floor((distance % (1000 * 60)) / 1000);
        return `${days} Days ${hours} hrs ${minutes} minutes ${seconds} seconds`
            },
            makeBid() {
              if (this.bidValue > 0) {
              this.socket.emit("makeBid", { bidValue: this.bidValue, user: this.$store.getters.getUser.id, product: this.product.id });
                }
            }
        },
        created() {
            this.socket.emit("loadBids", { id: this.$route.params.id });
            this.socket.on("loadBids", (data) => {
                this.product = data;
                this.bids = data.bids;
            });
        },
        mounted() {
            this.socket.on("serverTime", (data) => this.serverTime = data.time);
        }
    }
    </script>

We are looping through all available product images so that they can be displayed on bootstrap’s carousel. The Description of the Product is displayed beneath the carousel items while all the bids and the time left are displayed on the right side of the image. The UI can be tweaked to your liking. Each bid displays the username of the person who made the bid and the bid’s value. The sum of all bids is added to the base price to get the bid_price

Rendered Product Detail Component

Conclusion

This article guided you on how to build a real-time bidding system with Vue.js as the frontend and Strapi as the backend. We created a full-duplex communication channel with the help of the socket io client and server library to manipulate and broadcast data to specific event listeners. The article also showcased how to extend Strapi plugins and build custom collection services.

You can download the source code from the following repositories:

  1. Vue Frontend
  2. Strapi Backend

Watch a demo of the app here.