Let’s create Vue 3 Chat app frontend that will use Laravel RESTful API and WebSockets That we have made in the previous tutorials for creating a real-time chat app.
Course content
Creating Vue 3 app with Vite
Let’s create a new Vue.js 3 project with the independence we need.
npm create vite@latest chat-vue -- --template vue
cd my-vue-app
npm install
npm i bootstrap bootstrap-icons @vueuse/head @vuelidate/core @vuelidate/validators vue-router@4
npm install vuex@next --save
Here is src\main.js
file with everything we need, for now. We will create the missing files later.
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import "bootstrap/dist/css/bootstrap.min.css"
import "bootstrap"
import "bootstrap-icons/font/bootstrap-icons.css"
import router from "./router"
import store from "./store"
import { createHead } from '@vueuse/head'
const head = createHead()
createApp(App).use(head).use(router).use(store).mount('#app')
In the .env file, we will add the backend URL and WebSockets Key and Server domain.
VITE_BACKEND_URL = "http://127.0.0.1:8000/api"
VITE_WEBSOCKETS_KEY=local
VITE_WEBSOCKETS_SERVER=127.0.0.1
Let’s create the Vuex store for managing our application state and store the data that we share all over the app components and save them in the web storage in the browser for the next session.
Store
import { createStore } from 'vuex'
export default createStore({
state: {
token:'',
name:'',
id:'',
backendUrl: import.meta.env.VITE_BACKEND_URL,
stateLoaded: false
},
mutations: {
//getting saved data from the web storage
async initialiseStore(state) {
if(localStorage.getItem('token')){
state.token = localStorage.getItem('token');
}
if(localStorage.getItem('name')){
state.name = localStorage.getItem('name');
}
if(localStorage.getItem('image')){
state.image = localStorage.getItem('image');
}
if(localStorage.getItem('id')){
state.id = localStorage.getItem('id');
}
if(state.token == ""){
return false;
}
state.stateLoaded = true
return true;
},
//saving the login data from the login request
saveLogin(state,LoginData){
state.token =LoginData.token;
state.name =LoginData.name;
state.image =LoginData.image;
state.id =LoginData.id;
localStorage.setItem('token', LoginData.token);
localStorage.setItem('name', LoginData.name);
localStorage.setItem('image', LoginData.image);
localStorage.setItem('id', LoginData.id);
},
//remove all the data from web storage and store for logging out
Logout(state){
state.token ="";
state.name ="";
state.image ="";
state.id ="";
localStorage.removeItem('token');
localStorage.removeItem('name');
localStorage.removeItem('image');
localStorage.removeItem('id');
},
},
actions: {},
modules: {},
})
In the state object, we have token
is for an access token, which we get from the backend, name
and id
for the currently authenticated user, and the backend URL.
initialiseStore
this function we run when we start the app to check if the user logged in or not. we use localStorage
for saving the login data in the browser for the next time otherwise when the user closes the app the login data will be lost and the user must log in again.
saveLogin
we will use it when the user logged in for saving the data in the store localStorage
as well.
Logout
is for removing all the logged-in data so that the user will be logged out.
Routes
Let’s create the router file src\router\index.js
.
import { createRouter, createWebHistory } from 'vue-router'
import Home from '/src/views/Home.vue'
import DefaultLayout from '/src/layouts/DefaultLayout.vue'
import Login from '/src/views/Login.vue'
import Register from '/src/views/Register.vue'
import Chat from '/src/views/Chat.vue'
import store from '../store'
const routes = [
{
path:'/',
name:'Public',
component:DefaultLayout,
redirect: '/',
children:[
{
path:'/',
name:'Home',
component:Home
},
{
path: '/login',
name: 'Login',
component: Login,
},
{
path: '/register',
name: 'Register',
component: Register,
},
{
path: '/chat',
name: 'Chat',
component: Chat,
},
]
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
//if the user is not logged in, redirecting to login page
const except = ['Register' ]
router.beforeEach((to, from, next) => {
let token = store.state.token
console.log('router ==' + token)
if (to.name !== 'Login' && !token && !except.includes(to.name))
next({ name: 'Login' })
else next()
})
export default router
As you can see we have a default layout DefaultLayout
that contains the header component and the footer component of the web application. Let’s code it src\layouts\DefaultLayout.vue
<template>
<div>
<AppHeader/>
<router-view/>
<AppFooter/>
</div>
</template>
<script>
import AppHeader from '../components/AppHeader.vue'
import AppFooter from '../components/AppFooter.vue'
export default {
components:{AppHeader,AppFooter}
}
</script>
Let’s create a sample header for the chat app src\components\AppHeader.vue
<template>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark" aria-label="Main navigation">
<div class="container-fluid">
<router-link :to="{ name: 'Home' }" class="navbar-brand d-flex align-items-center">
<i class="bi bi-box2-heart h1 me-1"></i>
<strong>VUE</strong>
</router-link>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<router-link class="nav-link" :class="$route.name == 'Home'? 'active':''" aria-current="page" :to="{ name: 'Home' }">Home</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :class="$route.name == 'Chat'? 'active':''" :to="{ name: 'Chat' }">Chat</router-link>
</li>
</ul>
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<!-- Toggle the login or register routes depending on the user login-->
<li v-if="!$store.state.token" class="nav-item">
<router-link class="nav-link" :class="$route.name == 'Register'? 'active':''" :to="{ name: 'Register' }">Sign up</router-link>
</li>
<li v-if="!$store.state.token" class="nav-item">
<router-link class="nav-link" :class="$route.name == 'Login'? 'active':''" :to="{ name: 'Login' }">Sign in</router-link>
</li>
<li v-if="$store.state.token" class="nav-item dropdown ">
<a class="nav-link dropdown-toggle" href="#" id="dropdown01" data-bs-toggle="dropdown" aria-expanded="false">Account</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="dropdown01">
<li><a class="dropdown-item" @click="logout()" >Logout</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</template>
<script>
export default {
name: 'AppHeader',
data(){
return{
notifications:[]
}
},
methods: {
logout() {
this.$store.commit('Logout')
this.$router.push('/login')
},
},
}
</script>
In the header, we have created the logout method logout()
that triggers the Logout function in $store
then redirects the user to the login page.
It’s time for creating the footer src\components\AppFooter.vue
<template>
<footer class="text-muted py-5 bg-dark">
<div class="container">
<p class="float-end mb-1">
<a href="#">Back to top</a>
</p>
<p class="mb-1">All right reserved © 2024 example.com</p>
<p class="mb-0">About us <a href="/">Visit the homepage</a> or read our <a href="../getting-started/introduction/">Terms</a>.</p>
</div>
</footer>
</template>
Now, let’s create a submit button with a loading spinning circle that takes title
as the button text and isSendingForm
is boolean to toggle the spinning circle src\components\SubmitButton.vue
<template>
<!-- eslint-disable -->
<button type="submit" class="btn btn-primary" >
<div v-if="isSendingForm" class="spinner-border spinner-border-sm" role="status">
</div>
{{title}}
</button>
</template>
<script>
export default{
name: "SubmitButton",
props: ['title','isSendingForm'],
}
</script>
Let’s create the route pages, we will start with the Home page. we will make it too simple. src\views\Home.vue
<template>
<div class="container min-h-content py-5 text-center">
<div class="row py-lg-5">
<div class="col">
<h2>Welcome to the chat app</h2>
</div>
</div>
</div>
</template>
<script>
export default {
}
</script>
Next is the login page src\views\Login.vue
<template>
<section class="h-100 h-custom bg-light min-h-content mt-5" >
<div class="container py-5 h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col d-flex justify-content-center align-items-center">
<div class="card border-0 " style="min-width:350px; max-width:500px">
<div class="card-body">
<h3 class="mb-4">Sign in</h3>
<hr/>
<div class="alert alert-danger alert-dismissible fade show" v-if="ShowError" role="alert">
{{ errorMgs }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<form @submit="onSubmit">
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control"
v-model.trim="form.email"
@input="setTouched('email')"
:class="v$.form.email.$error?'is-invalid':''"
required>
<div class="invalid-feedback"> Please provide a correct email </div>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control"
v-model.trim="form.password"
@input="setTouched('password')"
:class="v$.form.password.$error?'is-invalid':''"
required>
<div class="invalid-feedback"> Please provide a password. </div>
</div>
<div class="mb-3 text-center">
<SubmitButton
title="Login"
:isSendingForm="isSendingForm"
/>
</div>
<hr/>
<div class="form-group d-md-flex mb-3">
<div class="w-50 text-start">
<label class="checkbox-wrap checkbox-primary mb-0">Remember Me
<input type="checkbox" checked>
<span class="checkmark"></span>
</label>
</div>
</div>
</form>
<p class="text-center">
Not a member? <router-link class="mt-3" :to="{ name: 'Register'}"> Sign up </router-link>
</p>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script>
/* eslint-disable */
import axios from 'axios'
import useVuelidate from '@vuelidate/core'
import { required, email } from '@vuelidate/validators'
import SubmitButton from '../components/SubmitButton.vue'
export default {
name: 'Login',
setup() {
return { v$: useVuelidate() }
},
components:{SubmitButton},
data() {
return {
form: {
email: '',
password: '',
},
errorMgs: '',
ShowError: false,
show: true,
isSendingForm: false,
}
},
validations() {
return {
form: {
email: {
required,
email,
},
password: {
required,
},
},
}
},
methods: {
setTouched(theModel) {
if(theModel == 'email' || theModel == 'all' ){this.v$.form.email.$touch()}
if(theModel == 'password' || theModel == 'all'){this.v$.form.password.$touch()}
},
async onSubmit(event) {
event.preventDefault()
this.setTouched('all');
if (!this.v$.$invalid)
{
this.isSendingForm = true;
axios.post(
this.$store.state.backendUrl+'/login',this.form,
{
headers: {"Content-Type": "application/json",}
})
.then((response) => {
console.log(response);
this.$store.commit('saveLogin',
{
"token":response.data.token,
"name":response.data.name,
"image":response.data.image,
"id":response.data.id,
});
this.$router.push('/')
this.isSendingForm = false;
})
.catch( (error) => {
console.log(error);
this.ShowError=true;
this.errorMgs = error.response.data.error;
this.isSendingForm = false;
});
}
},
},
}
</script>
The final page is the registration page src\views\Register.vue
<template>
<section class="h-100 h-custom bg-light min-h-content" >
<div class="container py-5 h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col d-flex justify-content-center align-items-center">
<div class="card border-0 " style="min-width:350px; max-width:500px">
<div class="card-body">
<h3 class="mb-4">Sign up</h3>
<hr/>
<div class="alert alert-danger alert-dismissible fade show" v-if="ShowError" role="alert">
{{ errorMgs }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<form @submit="onSubmit">
<div class="mb-3">
<label class="form-label">First name</label>
<input type="text" class="form-control"
v-model.trim="form.first_name"
@input="setTouched('first_name')"
:class="v$.form.first_name.$error?'is-invalid':''"
required>
<div class="invalid-feedback"> Please provide your first name </div>
</div>
<div class="mb-3">
<label class="form-label">Last name</label>
<input type="text" class="form-control"
v-model.trim="form.last_name"
@input="setTouched('last_name')"
:class="v$.form.last_name.$error?'is-invalid':''"
required>
<div class="invalid-feedback"> Please provide your Last name</div>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control"
v-model.trim="form.email"
@input="setTouched('email')"
:class="v$.form.email.$error?'is-invalid':''"
required>
<div class="invalid-feedback"> Please provide a correct email </div>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control"
v-model.trim="form.password"
@input="setTouched('password')"
:class="v$.form.password.$error?'is-invalid':''"
required>
<div class="invalid-feedback"> Please provide a password. </div>
</div>
<div class="mb-3">
<label class="form-label">Repeat your password</label>
<input type="password" class="form-control"
v-model.trim="form.confirmPassword"
@input="setTouched('confirmPassword')"
:class="v$.form.confirmPassword.$error?'is-invalid':''"
required>
<div class="invalid-feedback"> Please re-enter password. </div>
</div>
<div class="mb-3 text-center">
<SubmitButton
title="Sign up"
:isSendingForm="isSendingForm"
/>
</div>
<hr/>
</form>
<p class="text-center">
A member? <router-link class="mt-3" :to="{ name: 'Login'}"> Sign in </router-link>
</p>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script>
/* eslint-disable */
import axios from 'axios'
import useVuelidate from '@vuelidate/core'
import { required, email, sameAs } from '@vuelidate/validators'
import SubmitButton from '../components/SubmitButton.vue'
export default {
name: 'Login',
setup() {
return { v$: useVuelidate() }
},
components:{SubmitButton},
data() {
return {
form: {
email: '',
password: '',
first_name: '',
last_name: '',
confirmPassword: '',
recaptcha_token:'',
},
errorMgs: '',
ShowError: false,
show: true,
isSendingForm: false,
}
},
validations() {
return {
form: {
email: {
required,
email,
},
password: {
required,
},
first_name: {
required,
},
last_name: {
required,
},
confirmPassword: {
sameAsPassword: sameAs(this.form.password),
},
},
}
},
methods: {
setTouched(theModel) {
if(theModel == 'email' || theModel == 'all' ){this.v$.form.email.$touch()}
if(theModel == 'password' || theModel == 'all'){this.v$.form.password.$touch()}
if(theModel == 'first_name' || theModel == 'all'){this.v$.form.first_name.$touch()}
if(theModel == 'last_name' || theModel == 'all'){this.v$.form.last_name.$touch()}
if(theModel == 'confirmPassword' || theModel == 'all'){this.v$.form.confirmPassword.$touch()}
},
async onSubmit(event) {
event.preventDefault()
this.setTouched('all');
if (!this.v$.$invalid)
{
this.isSendingForm = true;
axios.post(
this.$store.state.backendUrl+'/register',this.form,
{
headers: {"Content-Type": "application/json",}
})
.then((response) => {
console.log(response);
this.$store.commit('saveLogin',
{
"token":response.data.token,
"name":response.data.name,
"image":response.data.image,
});
this.$router.push({
name: 'emailValidation',
params: { email: this.form.email.replace(".", "--")},
})
this.isSendingForm = false;
})
.catch( (error) => {
console.log(error);
this.ShowError=true;
this.errorMgs = error.response.data.error;
this.isSendingForm = false;
});
}
},
},
}
</script>
Here is the style sheet src\style.css
.min-h-content{
min-height: calc(100vh - 82px - 148px);
}
Finally the src\App.vue
component, we add the app title and description in addition to starting the initialiseStore
function to load all the data from the web storage.
src\App.vue
<template>
<router-view/>
</template>
<script>
import {computed, reactive } from 'vue'
import { useHead } from '@vueuse/head'
export default {
setup() {
//creating dynamic meta tags
const siteData = reactive({
title: `My Chat App`,
description: `My beautiful Chat App`,
})
useHead({
// Can be static or computed
title: computed(() => siteData.title),
meta: [
{
name: `description`,
content: computed(() => siteData.description),
},
],
})
},
async mounted() {
//loading the store data when the app start
await this.$store.commit('initialiseStore')
}
}
</script>
In the next tutorial, we will create the chat page with real-time chat using Laravel Echo: Vue 3 Laravel Echo WebSocket | Chat App 05.