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.
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.
- 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.
- Create your administrator account then you’ll be welcomed with the page below.
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.
- Click on Content-type Builder under plugins in the side navigation bar.
- Click on Create new collection type.
- Type
Product
for the Display name and click Continue. - Click the Text field button.
- Type
name
in the Name field. - Click on Add another field.
- Repeat the above steps for the following fields with the corresponding field types.
auction_end
- Datetime withdatetime(ex: 01/01/2022 00:00AM)
typeprice
- Number withbig integer
formatimage
- Media (Multiple Media)weight
- Number withdecimal
formatbid_price
- Number withbig integer
formatdescription
- Rich Textauction_start
- Datetime withdatetime(ex: 01/01/2022 00:00AM)
typeavailable
- Boolean
- 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.
Account Collection
We are going to repeat the above processes but in the account context. Use the steps below to guide you through.
- Click on Content-type Builder under plugins in the side navigation bar.
- Click on Create new collection type.
- Type
Account
for the Display Name and click Continue. - Click the Number field button.
- Type
balance
in the Name field and select thebig integer
Number format. - Click on Add another field.
- 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 typeaccount
on the field name input - Select the option that illustrates two circles joined by one line as shown below
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.
Bid Collection
- Click on Content-type Builder under plugins in the side navigation bar.
- Click on Create new collection type.
- Type
B
id for the Display name and click Continue. - Click the Number field button.
- Type
value
in the Name field. - Click on Add another field.
- Click Relation
- On the dialog containing Bid as the title, enter the field name as
account
- On the other dialog select
**Account**
and typebids
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
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.
- Click on Add another field
- Click Relation
- On the dialog containing Bid as the title, enter the field name as
account
- On the other dialog select
**Product**
and typebids
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
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
.
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.
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 parameteruser_id
is used to create an account for that specific user. Each account will have an initialbalance
value of 0. - The
**getUserAccount**
function enables us to fetch an account associated with a specific user. It also takesuser_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 thecreatedAt
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 theparseInt(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:
- Content Manager — Used to create, view, edit and delete records belonging to a specific collection.
- Content Type Builder — Used to manipulate collections. We used this earlier to create our collections.
- Email — Handles all email delivery actions.
- Media Library — Handles actions related to file uploads. We’ll use it to upload product images
- Internationalization — This plugin is responsible for converting content to other specified languages.
- 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 project’s `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.
- On the side navigation menu in the administrator panel, click on Settings
- Click on Roles under the User & Permissions plugin section
- Click Add new role
- Type
Bidder
in the name field - Type
Can create bids and view product listings
in the description field. - In the Permissions section click on Products to display available options
- Select
find
andfindOne
then click the save button
- 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.
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:
- Mutations — These are functions that modify our state variables. They are invoked by the store’s commit function.
- 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 - State — A function that defines all the variables we want to save and persist.
- 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>
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>
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=/>
<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>
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=/></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
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: