Vue.js Chat App Frontend | Chat App 04

Vue.js Chat App Frontend

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

Building Laravel & Vue.js Chat App 00 | Introduction
Laravel Login And Register RESTful API | Chat app 01
Laravel Chat RESTful API | Chat App 02 
Laravel WebSockets Chat | Chat App 03
Vue.js Chat App Frontend | Chat App 04
Vue 3 With Laravel Echo WebSocket | Chat App 05

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 &copy; 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.