Frontend for Shop on Laravel 8 (using Laravel Jetstream)

This article is the third in a series about creation of a pizza shop on Laravel 8 + Inertia. Past articles was about creating migrations and models, as well as creating a backend on Laravel, read the links.

Today I will talk about how to create a frontend for a shop on Laravel 8 using the application scaffolding Laravel Jetstream introduced in the new version.

Frontend for pizza Shop on Laravel 8 (using Laravel Jetstream)

What is Laravel Jetstream?

Laravel Jetstream is a standalone library provided for conveniently scaffolding applications (frontend) developed with Laravel 8.

Jetstream natively includes some of the functionality that most web applications require, such as registration, login, email confirmation, and a few others.

Laravel Jetstream comes in two scaffolding flavors: Livewire or Inertia.

Livewire is worth choosing if you want Laravel Blade to be your templating language.

Inertia uses Vue.js as its templating language, so you get a complete Vue application without the need for complex client-side routing.

Installing Laravel Jetstream

To connect Laravel Jetstream to a recently installed project, call the command:

composer require laravel/jetstream

Further, depending on which stack you have chosen, start…

To install Jetstream with Livewire:

php artisan jetstream:install livewire

To install Jetstream with Inertia:

php artisan jetstream:install inertia

You can also add the --teams flag to the aforementioned commands to add team support to your application, but in our case, we didn’t need it to create a frontend for a shop in Laravel 8.

Building a frontend with Laravel Inertia

To start creating a frontend on Laravel Inertia, we need to implement our layout. So let’s start by opening the resources\views\app.blade.php file and putting everything inside the head tag and possibly changing the classes in the body tag. As you can see, inside the body tag is the @inertia call. Thus, Inertia.js will be responsible for all content located in this place.

We also plan to work with cookies in the project, so we should include the vue-cookies dependency. To do this, run the following command at the root of the project.

npm i vue-cookies

Now you need to include it in the resources\js\app.js file so that working with cookies in Laravel is available within the entire application. Just add the line first:

import VueCookies from 'vue-cookies';

And then:

Vue.use(VueCookies);

Our next step is to create common vue files for the footer and navigation in the header of our shop. For them, create a new resources\js\Common folder. I will add navigation to the file HeaderNav.vue, now it looks like this.

<template>
    <nav class="navbar navbar-expand-lg navbar-dark ftco_navbar bg-dark ftco-navbar-light" id="ftco-navbar">
        <div class="container">
            <inertia-link href="/" class="navbar-brand" >PizzaProject</inertia-link>
            <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#ftco-nav" aria-controls="ftco-nav" aria-expanded="false" aria-label="Toggle navigation">
                <span class="oi oi-menu"></span> Menu
            </button>

            <div class="collapse navbar-collapse" id="ftco-nav">
                <ul class="navbar-nav ml-auto">
                    <li class="nav-item active"><inertia-link href="/" class="nav-link" >Home</inertia-link></li>
                    <li class="nav-item active" v-if="$page.user" ><inertia-link href="/dashboard" class="nav-link" >Orders</inertia-link></li>
                    <li class="nav-item active" v-if="!$page.user" ><a href="/login" class="nav-link" >Login</a></li>
                    <li class="nav-item active" v-if="!$page.user" ><a href="/register" class="nav-link" >Register</a></li>
                    <li class="nav-item active"><inertia-link href="/cart" class="nav-link" ><span class="icon-shopping_cart"></span>[{{cart_items_count}}]</inertia-link></li>
                    <li class="nav-item active" v-if="$page.user" >
                        <inertia-link href="#" @click.prevent="logout" class="nav-link">
                            Logout
                        </inertia-link >
                    </li>
                </ul>
            </div>
        </div>
    </nav>
    <!-- END nav -->
</template>

<script>
    import JetNavLink from './../Jetstream/NavLink';

    export default {
        components: {
            JetNavLink,
        },
        props: [
            'cart_items_count'
        ],
        methods: {
            logout() {
                axios.post('/logout').then(response => {
                    window.location = '/';
                })
            },
        }
    }
</script>

As you can see, the file follows the normal Vue.js structure. At the top in the template tag is the html code, and at the bottom in the script tag is the javascript code.

Of the features, it is worth noting that instead of standard links with the a tag, the inertia-link tag is used so that the transition on them occurs without reloading the page. For everything to work, the JetNavLink package must be connected, as shown above. Also here as props will come cart_items_count, which will display how many items are in the cart. The variable $page.user stores data about whether the user is authorized or not, so depending on this, we display certain links. And by clicking on the Logout link, the corresponding method will be called, in which Axios will send a post request to log out the authorized user.

The second file in the Common folder will be Footer.vue. There will be a lot of code in it, so I won’t insert it in full. You can see the complete code in the repository on Github, indicated at the end of the article. The most important point, in addition to different links in the Footer file, will be changing the site currency, it is carried out in the usual select tag.

<select v-model="currency" @change="currencyChange">
    <option value="USD">USD</option>
    <option value="EUR">EUR</option>
</select>

And in the script at the bottom of the file, we specify the following.

export default {
    data() {
        return {
            currency: 'USD'
        }
    },
    mounted() {
        if (this.$cookies.get('currency') !== null) {
            this.currency = this.$cookies.get('currency');
        } else {
            this.$cookies.set('currency', this.currency);
        }
    },
    methods: {
        async currencyChange() {
            this.$cookies.set('currency', this.currency);
            this.emitToParentPage();
        },
        emitToParentPage () {
            this.$emit('currency', this.currency);
        },
    }
}

As you can see, we will store the site currency in cookies. In mounted, we check if a cookie named currency already exists, and if not, we set it from the current value, and if it does, we get it. And the methods are needed in order to inform the page that connects this Footer about this when changing the currency.

Main page

Now it’s time to create a main pagein out shop on Laravel 8 containing a menu listing our shop’s products (with pizzas). I wrote in the last article how to create a backend for a shop on Laravel 8, so I will not repeat about how routes and controllers are created, read it and everything is written in detail there. Let me just remind you that the following line was used to connect the file in the controller.

return Inertia::render('Index');

It says that Inertia will look for the Index.vue file in the resources\js\Pages folder, where we must create it. In it, the HeaderNav tag connects the previously created navigation in the header and passes there a parameter with the number of items in the cart.

<HeaderNav :cart_items_count='cart_items_count' />

In the Footer tag, we connect the footer and get the current currency from it.

<Footer v-on:currency="currencyChange" />

Both of these components must be included in the script on the page (and all other pages that we create).

<script>
    import AppLayout from './../Layouts/AppLayout';
    import HeaderNav from './../Common/HeaderNav';
    import Footer from './../Common/Footer';

    export default {
        props: [
            // 'pizzas',
        ],
        components: {
            AppLayout,
            HeaderNav,
            Footer
        },
        data() {
            return {
                cart_items_count: 0,
                carts: {},
                pizzas: {},
                currency: 'USD'
            }
        },
        mounted() {
            if ( this.$cookies.get('cart_items_count') !== null ){
                this.cart_items_count = this.$cookies.get('cart_items_count');
            }
            if ( this.$cookies.get('carts') !== null ){
                this.carts = this.$cookies.get('carts');
            }
            if ( this.$cookies.get('currency') !== null ){
                this.currency = this.$cookies.get('currency');
            }
            this.getPizzas();
            // console.log(env.baseU);
            // console.log(env);
        },
        methods: {
            async getPizzas() {
                const params = {
                    currency: this.currency
                };
                axios.get('/api/get_pizzas', { params }).then(response => {
                    this.pizzas = response.data;
                });
            },
            addToCart(event, pizza_id) {
                event.preventDefault();
                this.addItemInCart(pizza_id);
            },
            buyNow(event, pizza_id) {
                event.preventDefault();
                this.addItemInCart(pizza_id);
                this.$inertia.visit('/cart');
            },
            addItemInCart(pizza_id) {
                this.cart_items_count++;
                this.$cookies.set('cart_items_count', this.cart_items_count);

                if ( Object.keys(this.carts).length > 0 ) {
                    let found = false;

                    for (let pizza_id_key in this.carts) {
                        if (pizza_id_key == pizza_id) {
                            this.carts[pizza_id_key]++;
                            found = true;
                        }
                    }
                    if ( ! found) {
                        this.carts = Object.assign(this.carts, {
                            [pizza_id]: 1
                        });
                    }

                } else {
                    this.carts = Object.assign(this.carts, {
                        [pizza_id]: 1
                    });
                }
                this.$cookies.set('carts', this.carts);
            },
            currencyChange(value) {
                this.currency = value;
                this.getPizzas();
            },
        }
    }
</script>

In mounted, you can see how from cookies we get the number of products in the cart – cart_items_count, an array of carts, in which the key is the id of the product, and the value is the quantity and currency. Also here we turn to the getPizzas method, in which axios, using a get request, gets a list of products from the backend, where we pass the currency of our choice as a parameter. We write the result (received goods) into the pizzas variable.

this.pizzas = response.data;

After that, the products will be displayed on the page.

<div class="col-sm-12 col-md-6 col-lg-3  d-flex" v-for="pizza in pizzas">
    <div class="product d-flex flex-column">
        <inertia-link :href="'/pizza/'+pizza.id" class="img-prod" >
            <img class="img-fluid" :src="pizza.image_url" :alt="pizza.name">
            <div class="overlay"></div>
        </inertia-link>
        <div class="text py-3 pb-4 px-3">
            <h3><a href="#">{{pizza.name}}</a></h3>
            <div class="pricing">
                <p class="price"><span>{{pizza.sign}}{{pizza.price}}</span></p>
            </div>
            <p class="bottom-area d-flex px-3">
                <a href="#" class="add-to-cart text-center py-2 mr-1" @click="addToCart($event, pizza.id)" ><span>Add to cart <i class="ion-ios-add ml-1"></i></span></a>
                <a href="#" class="buy-now text-center py-2" @click="buyNow($event, pizza.id)" >Buy now<span><i class="ion-ios-cart ml-1"></i></span></a>
            </p>
        </div>
    </div>
</div>

There are 2 buttons in the code that, when you click on them, call the addToCart and buyNow methods. The only difference is that when they click on the first one, the user puts the product in the cart and can continue to select, while clicking on the second one will immediately be redirected to the shopping cart page.

Product page

The code on the product page (in the Pizza.vue file) is very similar to the code on the main page, only in mounted the getPizza method is called, which gets the product id from the line in the browser and, in accordance with it, requests a single product via the API, and not all, as in the main page.

async getPizza() {
    let pizza_id = window.location.href.split('/').pop();
    const params = {
        currency: this.currency
    };
    axios.get('/api/get_pizza/'+pizza_id, { params }).then(response => {
        this.pizza = response.data;
    });
},

And the output will also be not products in a cycle, but one product.

<div class="row">
    <div class="col-lg-6 mb-5 ">
        <a :href="pizza.image_url" class="image-popup prod-img-bg"><img :src="pizza.image_url" class="img-fluid" :alt="pizza.name"></a>
    </div>
    <div class="col-lg-6 product-details pl-md-5 ">
        <h3>{{pizza.name}}</h3>
        <p class="price"><span>{{pizza.sign}}{{pizza.price}}</span></p>
        <p>{{pizza.description}}</p>
        <p>
            <a href="#" class="btn btn-black py-3 px-5 mr-2" @click="addToCart($event, pizza.id)" >Add to Cart</a>
            <a href="#" class="btn btn-primary py-3 px-5" @click="buyNow($event, pizza.id)" >Buy now</a>
        </p>
    </div>
</div>

Cart page

On the cart page (in the Cart.vue file) we will display all the products that the user has added to the cart and calculate the amount that he/she will have to pay. Here, in mounted, we will call the getCart method, which will send the selected currency and the previously generated carts array to the backend and receive from there all the cost parameters of the selected goods and order.

async getCart() {
    const params = {
        carts: this.carts,
        currency: this.currency
    };
    axios.get('/api/get_cart', { params }).then(response => {
        if (response.data.success) {
            this.cart_items = response.data.pizzas;
            this.subtotal = response.data.subtotal;
            this.delivery = response.data.delivery;
            this.discount = response.data.discount;
            this.total = response.data.total;
            this.sign = response.data.sign;
        }
    });
},

They will be displayed on the page.

    <div class="row">
        <div class="col-md-12 ">
            <div class="cart-list">
                <table class="table">
                    <thead class="thead-primary">
                    <tr class="text-center">
                        <th> </th>
                        <th> </th>
                        <th>Product</th>
                        <th>Price</th>
                        <th>Quantity</th>
                        <th>Total</th>
                    </tr>
                    </thead>
                    <tbody>
                    <tr class="text-center" v-for="cart_item in cart_items" >
                        <td class="product-remove"><a href="#" @click="removeItem($event, cart_item.id)"><span class="ion-ios-close"></span></a></td>

                        <td class="image-prod"><div class="img" :style="'background-image:url('+cart_item.image_url+');'"></div></td>

                        <td class="product-name">
                            <h3>{{cart_item.name}}</h3>
                            <p>{{cart_item.description}}</p>
                        </td>

                        <td class="price">{{cart_item.sign}}{{cart_item.price}}</td>

                        <td class="quantity">
                            <div class="input-group mb-3">
                                <input type="number" name="quantity" class="quantity form-control input-number" :value="cart_item.quantity" min="1" max="100" @change="onQuantityChange($event, cart_item.id, $event.target.value)">
                            </div>
                        </td>

                        <td class="total">{{cart_item.sign}}{{cart_item.total}}</td>
                    </tr><!-- END TR-->

                    </tbody>
                </table>
            </div>
        </div>
    </div>
    <div class="row justify-content-start">
        <div class="col col-lg-5 col-md-6 mt-5 cart-wrap ">
            <div class="cart-total mb-3">
                <h3>Cart Totals</h3>
                <p class="d-flex">
                    <span>Subtotal</span>
                    <span>{{sign}}{{subtotal}}</span>
                </p>
                <p class="d-flex">
                    <span>Delivery</span>
                    <span>{{sign}}{{delivery}}</span>
                </p>
                <p class="d-flex">
                    <span>Discount</span>
                    <span>{{sign}}{{discount}}</span>
                </p>
                <hr>
                <p class="d-flex total-price">
                    <span>Total</span>
                    <span>{{sign}}{{total}}</span>
                </p>
            </div>
            <p class="text-center"><inertia-link href="/checkout" class="btn btn-primary py-3 px-4" >Proceed to Checkout</inertia-link></p>
        </div>
    </div>

Here, the user can also delete some product from the cart, when clicking on the corresponding button, the method will be called.

async removeItem(event, pizza_id) {
    event.preventDefault();
    console.log(this.carts);

    this.cart_items_count = this.cart_items_count - this.carts[pizza_id];
    this.$cookies.set('cart_items_count', this.cart_items_count);
    delete this.carts[pizza_id];
    this.$cookies.set('carts', this.carts);

    this.getCart();
},

And if the user wants to change the quantity of the selected product, then it will be recalculated in the method.

async onQuantityChange(event, pizza_id, val) {
    event.preventDefault();
    let diff = this.carts[pizza_id] - val;
    for (let pizza_id_key in this.carts) {
        if (pizza_id_key == pizza_id) {
            this.carts[pizza_id] = val;
        }
    }
    this.cart_items_count = this.cart_items_count - diff;
    this.$cookies.set('cart_items_count', this.cart_items_count);
    this.$cookies.set('carts', this.carts);
    this.getCart();
},

Now that the products in the cart are formed, by clicking on the “Proceed to Checkout” button we will proceed to checkout.

Checkout page

The Checkout page in our frontend is represented in the Checkout.vue file. On it, we, just like in the cart, get its contents using the getCart method to get the order cost.

The page has many fields that the user will need to fill in (first name, last name, address, phone, email). They are created in a similar way.

<div class="form-group">
    <label >First Name</label>
    <span v-if="errors.firstname" class="error_red" > - {{errors.firstname[0]}}</span>
    <input type="text" class="form-control" v-model="firstname" >
</div>

A regular input field with a matching v-model. And the span tag that will be shown if there is an error associated with this field.

The is_create_account flag indicates whether the user wants to create a new account (or he is already authorized) and link an order to it. If he wishes, he will also need to set a password for the account.

<div class="col-md-12">
    <div class="form-group mt-4">
        <div class="radio">
            <label v-if="!$page.user"><input type="radio" v-model="is_create_account" value="1" > Create an Account? </label>
            <label v-if="!$page.user"><input type="radio" v-model="is_create_account" value="2" > Don't create</label>
               <label v-if="!$page.user">( or <a href="/login"  >Login</a>)</label>
            <label v-if="$page.user"><input type="radio" v-model="is_create_account" value="3" > Authorized as {{$page.user.name}}</label>
        </div>
    </div>
</div>

<div class="col-md-12" v-if="is_create_account == 1 && !$page.user" >
    <div class="form-group">
        <label >Password</label>
        <span v-if="errors.password" class="error_red" > - {{errors.password[0]}}</span>
        <input type="password" class="form-control" v-model="password" >
    </div>
</div>

When all the fields are filled in, the customer clicks the “Place an order” button and the createOrder method will be triggered, which will collect all the necessary data to create an order and send post requests.

async createOrder(event) {
    event.preventDefault();
    const params = {
        carts: this.carts,
        firstname: this.firstname,
        lastname: this.lastname,
        address: this.address,
        phone: this.phone,
        email: this.email,
        is_create_account: this.is_create_account,
        password: this.password,
    };
    axios.post('/api/checkout', params).then(response => {
        console.log(response.data);
        if (response.data.success) {
            this.carts = {};
            this.cart_items_count = 0;
            this.$cookies.set('carts', this.carts);
            this.$cookies.set('cart_items_count', this.cart_items_count);
            this.$inertia.visit('/success');

        } else {
            this.errors = response.data.errors;
            alert('Some error');
        }
    });
}

If the order is successfully created, the data on the items in the cart will be cleared and the customer will be redirected to the success page. This page was created in the file Success.vue, but it contains just text informing user about a successful purchase, so there is no point in writing in detail about its creation. Therefore, we will immediately proceed to creating a page in the user’s account with a list of his orders.

User orders page

Since the registration and authorization pages are presented in Laravel Jetstream out of the box and are located in the resources/views/auth folder (files login.blade.php and register.blade.php), we will not write about them in detail.

I will write about the user orders page, to which he will be redirected after authorization. The Orders.vue file will be responsible for it. It calls the getOrders method in mounted, which gets the user’s orders via the API.

async getOrders() {
    const auth = {
        headers: {
            Accept: 'application/json',
            Authorization: 'Bearer ' + this.$page.user_token,
        },
        params: {
            user_id: this.$page.user.id,
            currency: this.currency
        }
    };
    axios.get('/api/get_user_orders', auth  ).then(response => {
        if (response.data.success) {
            this.orders = response.data.orders;
        }
    });
},

We get the user_token used in the method from the controller, I wrote in detail about creating controllers in Laravel 8 in the last article.

After receiving the user’s orders, we display them on the page in the table.

<table class="table" v-if="Object.keys(orders).length > 0" >
    <thead>
    <tr>
        <th scope="col">#</th>
        <th scope="col">Firstname</th>
        <th scope="col">Lastname</th>
        <th scope="col">Subtotal</th>
        <th scope="col">Delivery</th>
        <th scope="col">Discount</th>
        <th scope="col">Total</th>
        <th scope="col">Handle</th>
    </tr>
    </thead>
    <tbody v-for="order in orders">
    <tr >
        <th scope="row">{{order.id}}</th>
        <td>{{order.firstname}}</td>
        <td>{{order.lastname}}</td>
        <td>{{order.sign}}{{order.subtotal}}</td>
        <td>{{order.sign}}{{order.delivery}}</td>
        <td>{{order.sign}}{{order.discount}}</td>
        <td>{{order.sign}}{{order.total}}</td>
        <td>
            <a class="btn btn-primary" data-toggle="collapse" :href="'#collapse'+order.id" role="button" aria-expanded="false" :aria-controls="'#collapse'+order.id">
                More info
            </a>
        </td>
    </tr>
    <tr class="collapse" :id="'collapse'+order.id">
        <th></th>
        <td><b>Address</b>: <br>{{order.address}}</td>
        <td><b>Phone</b>: <br>{{order.phone}}</td>
        <td><b>Email</b>: <br>{{order.email}}</td>
        <td colspan="4">Order Items:</td>
    </tr>
    <tr class="collapse" :id="'collapse'+order.id" v-for="pizza in order.pizzas">
        <td colspan="4"></td>
        <td><b>Pizza name</b>: <br>{{pizza.name}}</td>
        <td><b>Price</b>: <br>{{pizza.sign}}{{pizza.price}}</td>
        <td><b>Quantity</b>: <br>{{pizza.count}}</td>
        <td><b>Total</b>: <br>{{pizza.sign}}{{pizza.total}}</td>
    </tr>
    </tbody>
</table>

Thus, not only data about the order (entered by the user) is displayed here, but also complete information, including data about each product, quantity and its price.

At this step, we managed to create a frontend for a shop on Laravel 8 using Laravel Jetstream (or rather, its variant is Laravel Inertia).

We now have a functioning Laravel pizza shop, you can find the full code on my Github.

Be sure to share this post on social media if you find it useful. And subscribe to my Twitter account so as not to miss new posts.

Share post
Twitter Facebook
« »
Contact About

© 2021 Andreyblog.