Screenshot 2023-10-25 at 10.48.41โ€ฏPM.png
Building An eBook Store on Frappe: Part 3, Interactivity & Deploy
In this final part, we will add interactivity to our eBook storefront, do some polish and deploy it to Frappe Cloud.
image7f7d4e.png

By

Hussain Nagaria

ยท

Feb, 16 2023

ยท

11

min read

So, Where Were We?

In the last two parts, we developed the storefront of our eBook store, integrated a payment gateway and set up delivery via Email. In this part, we will polish our storefront, add interactivity using AlpineJS and deploy it to Frappe Cloud at the end.

The Series

You can jump to a specific part of this series using the links below:

  1. Part I: Storefront
  2. Part II: Payment & Delivery
  3. Part III: Polish, Interactivity & Deployment! (the one you are reading right now)

You can use the source code of Part II as a starting point for this tutorial.

Better Success State

If you recall, we were showing an alert message after the successful purchase of an eBook, which is pretty lame to be honest. Let's make it better. Here is what I have in mind:

  1. User gets navigated to a new page with a success message
  2. Plus, Confetti Animation!

Building The Success Page

We will build the success page first and then add the confetti animation using the canvas-confetti library. Let's start!

I want the success page to be accessible at the path: /store/success on our portal. For this to happen, we will have to create a file named success.html in the directory www/store. But, in the current state of our code, we don't have the directory www/store.

We had created a file named store.html for our store page. We would get the same outcome by creating a file named index.html inside a directory named store. So, below two implementations are identical in terms of routing:

www/store.html --> /store
www/store/index.html --> /store

But in the second case, we can create nested pages, for instance:

www/store/hello.html --> /store/hello
www/store/success.html --> /store/success

Let's quickly refactor our store page to use directory based routing. Here are the steps:

  1. Create a new directory named store inside the www directory
  2. Create 3 new files inside this directory: index.html, index.py and __init__.py
  3. Copy the contents of store.html to store/index.html and copy the contents of store.py to store/index.py
  4. Feel free to delete the store.html and store.py files

This is how your directory should look before and after the refactor:

You can now visit /store on your site and the store portal should work as it worked before the refactor.

We can now add a new file success.html to www/store directory and add in the below contents:

{% extends "templates/store/base.html" %}

{% block body %}

<div class="container">
    <h1 class="is-size-1 my-6 is-bold">Purchase Successful!</h1>
    <img src="/assets/star_ebook_store/images/on_the_way.svg" alt="eBook on the way illustration">
    <h2 class="is-size-5 my-6">Your eBook is on the way! Check your inbox!</h1>
</div>

{% endblock %}

I have just used some classes from Bulma to make the page look good. I have also added an illustration named "On the way" from unDraw. You can place any static assets you want in the public directory of your custom app:

And they will be served at /assets/<app-name>/.... You can learn more about serving static assets in Frappe here.

Let's navigate to the /store/success route now:

๐Ÿ”ฅ, right?! Wait, there is more. We will add a confetti animation to this page now. We are going to use the canvas-confetti JavaScript library to make it work.

It is very easy to use, we just need to add a few lines of code to our success.html page:


<!-- Load From CDN -->
{% block head_script_tags %}
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.5.1/dist/confetti.browser.min.js"></script>
{% endblock %}

{% block body %}

<div class="container">
    ...
</div>

<script>
    // Trigger Confetti
    confetti({
        particleCount: 200,
        spread: 120,
        origin: { y: 0.6 }
    });
</script>

{% endblock %}

... and done. Feel free to play around with the parameters passed to the confetti function. I have chosen the above values (count, spread etc.) by trying out a few different numbers.

Let's refresh our success page now:

Redirecting To Success

We want the user to be navigated to our newly built success page after the payment is successful. It is as easy as modifying our success handler code in ebook.html as shown below:

"handler": (res) => {
    // alert("Payment Success! Your eBook is on the way!")
    window.location = "/store/success";
}

Let's do a test purchase now:

The Routing Problem

If you recall, the route for viewing the details of a particular eBook is of the form: "/store/book-name" and in the last step, we created a success page that is accessible at "/store/success". Hm. If you think for a bit, you will realise the problem here. What if we have a book named success? What will happen if you visit "/store/success" then? Will the user see the success page or the eBook detail page?

Well, the easy way out would be to prevent creation of an eBook record named "Success". But what if, in the future, we want to add a "cart" page, accessible at "/store/cart". So, all in all (after all the What ifs ๐Ÿคฃ) we have to find a better solution. The solution is simple: change the base path for eBook details to "/store/ebook/book-name". Problem solved.

Open up ebook.py file and make the following change in the validate controller method:

# self.route = f"store/{cleanup_page_name(self.name)}"
self.route = f"store/ebook/{cleanup_page_name(self.name)}"

This will make sure the correct route is generated for an eBook record. But what about the eBook records that already exist in our database. How do we update those existing records? Of course we can write a one-off script to update the previous records. But what if (yeah, bear with me here) someone has already installed our app and created eBook records? This is where patches come in handy. Feel free to read some documentation on patches here before proceeding.

Basically, patches are run during the database migration step, during bench migrate if you will. We can use patches to silently carry out some process on or fix our site data.

Writing The Patch

Patches in Frappe Framework are basically Python functions that you can write anywhere inside your app. Here are the steps needed to write a patch to fix the ebook routes:

  1. Create a new Python file inside doctype/ebook/patches directory (you can place this file wherever you like inside your app, but this is a good convention) and name it whatever you feel describes what the patch does. I will name it fix_route.py, because it will fix the routes.
  2. Define a function named execute in this file. The function MUST be called execute. The contents of the file are:
import frappe
from frappe.website.utils import cleanup_page_name

def execute():
    # Fetch all the book records
    ebooks = frappe.get_all("eBook", pluck="name")

    for ebook_name in ebooks:
        # Update and set the route
        updated_route = f"store/ebook/{cleanup_page_name(ebook_name)}"

        frappe.db.set_value("eBook", ebook_name, "route", updated_route)
  1. Next, we will add this patch to our app's star_ebook_store/patches.txt file:
star_ebook_store.star_ebook_store.doctype.ebook.patches.fix_route

Go ahead and run the migrate command now:

$ bench --site booker.localhost migrate

The patch will now be executed and the routes should be fixed now. Off to a new problem!

Fixing The Sales Count

Let's fix this:

Now, there are two approaches to get this working:

  1. A separate database call to get the sales counts and then joining the data with the ebook details.
  2. A single SQL query which joins the tables required to get all the data at once.

Sit tight, things are about to get a little tense ๐Ÿ˜‰

Let me open the mariadb (mysql client) console and try to figure out the query we need to run.

To open your site's MariaDB console, run:

$ bench --site <site-name> mariadb

To get the number of sales for each eBook, we can run the below SQL query:

SELECT ebook, count(ebook) 
FROM `tabeBook Order` 
WHERE status = "Delivered"
GROUP BY ebook;

It produces the following result:

+----------------------------+--------------+
| ebook                      | count(ebook) |
+----------------------------+--------------+
| Never Split The Difference |            4 |
+----------------------------+--------------+
1 row in set (0.00 sec)

Now, we can either use a subquery or join the eBook and eBook Order tables to get the purchase count along with eBook information. I am going to go with a join here:

SELECT b.name, b.cover_image, b.route, a.full_name, count(o.name)  AS sales_count
FROM `tabeBook` as b 
JOIN `tabAuthor` as a 
    ON b.author = a.name 
JOIN `tabeBook Order` as o 
    ON o.ebook = b.name 
WHERE o.status = "Delivered" 
GROUP BY b.name
ORDER BY b.creation;

The above query produces the output similar to below:

+----------------------------+-----------------------------------------------------+----------------------------------------+------------+-------------+
| name                       | cover_image                                         | route                                  | full_name  | sales_count |
+----------------------------+-----------------------------------------------------+----------------------------------------+------------+-------------+
| Never Split The Difference | https://m.media-amazon.com/images/I/71b+W6NDruS.jpg | store/ebook/never-split-the-difference | Chris Voss |           4 |
+----------------------------+-----------------------------------------------------+----------------------------------------+------------+-------------+

Now, let's convert the above SQL to query builder code and then I will explain to you what our query is doing:

# The COUNT aggregation function
from frappe.query_builder.functions import Count

# The 3 DocTypes to join
EBook = frappe.qb.DocType("eBook")
Author = frappe.qb.DocType("Author")
EBookOrder = frappe.qb.DocType("eBook Order")

query = (
    frappe.qb.from_(EBook)
    .left_join(Author)
    .on(Author.name == EBook.author)
    .left_join(EBookOrder)
    .on((EBookOrder.ebook == EBook.name) & (EBookOrder.status == "Paid"))
    .where(EBook.is_published == True) # Only Published Ebooks
    .groupby(EBook.name)
    .select(
        EBook.route,
        EBook.cover_image,
        EBook.name,
        Author.full_name.as_("author_name"),
        Count(EBookOrder.name).as_("sales_count"),
    )
    .orderby(EBook.creation) # Newest books first
)

# execute the database query
ebooks = query.run(as_dict=True)

๐Ÿคฏ, right? Worry not! Let me explain it briefly. In the above query, we are basically joining the three DocTypes tables: eBook, Author, and eBook Order. We are joining the Author table to eBook table to get the full_name of the author, linked to the eBook.

We are also (left-)joining eBook Order to eBook and doing a GROUP BY to get the sales count ("Delivered" ebooks) for each ebook.

Let's open up our store/index.html file and replace the hardcoded value with our dynamic variable:

Now refresh the store home page and the sales counts should appear as expected:

Interactivity Time

We are going to use Alpine.js to add interactivity to our portal. You could also use VanillaJS if you like, but Alpine.js is very lightweight and has VueJS (which I just โค๏ธ) like syntax. The official site describes it as:

"Think of it like jQuery for the modern web"

On a side note, if we had used vanilla portal pages (which inherit from web.html template in Frappe), jQuery is already available globally.

Including via CDN

Add the below script tag to our ebook.html file's head_script_tags block:

<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>

Making The Tabs Work

As you will see now, we will be able to get the tabs working without writing a single line of JavaScript code!

Let's jump back into the ebook.html file and navigate to the code which renders the tabs section:

<section class="container">
    <div class="tabs">
        <ul>
            <li class="is-active"><a>Description</a></li>
            <li><a>TOC</a></li>
            <li><a>About the author</a></li>
        </ul>
    </div>

    <div>
        <p>{{ frappe.utils.md_to_html(doc.description) }}</p>
    </div>

    <div>
        <p>{{ frappe.utils.md_to_html(doc.toc) }}</p>
    </div>

    <div>
        <p>{{ frappe.utils.md_to_html(author.bio) }}</p>
    </div>
</section>

We will add interactivity to the above code in the below 4 steps:

Step 1: State Store

We need to track the active tab in order to conditionally render the tab content. AlpineJS provides the x-data directive for adding reactive data for a particular HTML chunk (component).

We will use this directive to track the active tab in the tabs section:

<section class="container" x-data="{activeTab: 'description'}">
    <div class="tabs">
        ...

I have set the initial value be "description" (the first tab in the list).

Step 2: Conditionally Rendering Tab Content

Currently contents of all the three tabs is being displayed:

Now based on the value of the activeTab property, we want to only display the content for that particular tab. Again, AlpineJS directives to the rescue:

<div x-show="activeTab == 'description'">
    <p>{{ frappe.utils.md_to_html(doc.description) }}</p>
</div>

<div x-show="activeTab == 'toc'">
    <p>{{ frappe.utils.md_to_html(doc.toc) }}</p>
</div>

<div x-show="activeTab == 'bio'">
    <p>{{ frappe.utils.md_to_html(author.bio) }}</p>
</div>

The x-show directive is a very powerful and useful directive from AlpineJS. It can help us to render a HTML node based on some specified condition. In the above code, we are using the x-show directive to render the body of a particular tab based on the value of the variable activeTab.

The result:

As you can see, now only the description is being rendered, because the activeTab has the value description.

Step 3: Handling Tab Link Click

Now, we want to change the value of the active tab when the user clicks one of the tab links. We can easily handle clicks using the v-on or @ (shorthand) directive provided by AlpineJS to attach event listeners to HTML elements. Let's add @click to all the three tab links:

<div class="tabs">
    <ul>
        <li @click="activeTab = 'description'"><a>Description</a></li>
        <li @click="activeTab = 'toc'"><a>TOC</a></li>
        <li @click="activeTab = 'bio'"><a>About the author</a></li>
    </ul>
</div>

If you try to click the TOC link:

Yay! We are now rendering the content based on the active tab. But wait, why is the TOC link not getting highlighted? Because we haven't done anything to make it work yet, duh ๐Ÿคฃ. Let's make it work next.

Step 4: Applying Active Class Dynamically

We need to add the is-active class to the li tag which we want to highlight. Again, we can do that dynamically/reactively using the v-bind directory. :class is shorthand for v-bind:class:

<li :class="activeTab == 'description' ? 'is-active': ''" @click="activeTab = 'description'"><a>Description</a></li>

<li :class="activeTab == 'toc' ? 'is-active': ''" @click="activeTab = 'toc'"><a>TOC</a></li>

<li :class="activeTab == 'bio' ? 'is-active': ''" @click="activeTab = 'bio'"><a>About the author</a></li>

In the above code snippet, we are setting the class to "is-active" or "" based on the value of the activeTab. As easy as that!

Go ahead and try to switch tabs now:

Deploying To Frappe Cloud

Our store is ready to be visited by anyone around the world. So, let's deploy our app to Frappe Cloud and make it live in minutes!

Before we begin, visit this link to sign up for a free Frappe Cloud account if you haven't already.

Creating A Private Bench

For deploying a custom app, we will first need a private bench on Frappe Cloud. It is very easy to create a new Frappe bench from FC dashboard:

  1. Visit the Benches dashboard using the navigation and click on the + New button:

  1. Select a title and version for the bench and click on the Create Bench button:

Adding App From GitHub

After the bench has been created, we can add our custom app from GitHub using the following steps:

  1. Visit the Apps tab in your private bench dashboard and click on Add App button:

  1. Scroll to the bottom and click on the Add from GitHub link:

  1. Connect your GitHub account and once the repo list is shown, search and select the star_ebook_store repository:

After selecting the application, click on Validate App button and then on Add App once the validation is complete.

Let's deploy the bench now:

Click on the Show Updates button and then click Deploy.

Creating A New Site

Once the deploy is complete, we can now create a new site on that bench:

Follow the site creation wizard by selecting a domain name, region, apps and pricing plan. Your site is now being provisioned:

The page will automatically show refreshed content once the site is active. Our site is live now and you can share it to the world! Be sure to add some cool eBooks to sell first!

More To Play & Learn!

If you want to have more fun and learn even more, feel free to extend this application by adding more features and enhancements. Here are a few ideas you can implement as a challenge:

  1. A Shopping Cart for the store and ordering multiple eBooks at a time
  2. Better handling of errors in the frontend with error messages etc.
  3. Create reports for summarising eBook sales happening on your store

Resources

  1. Source Code
  2. Frappe Framework Docs
  3. Bulma Docs
  4. AlpineJS Docs

Conclusion

Congratulations! You made it to the end! In this series, we have covered a lot of Frappe concepts and applied them to a real-world situation. But there are still a lot of powerful things in Frappe we haven't touched. I highly recommend you to check out frappe.school and the Frappe Framework documentation to learn more.

I hope you learned something out of this content and if you did, leave a comment below! Let me know what type of content you would like to see in the future and I will surely try to get that done. Till then, ๐Ÿ‘‹๐Ÿ‘‹๐Ÿ‘‹!

Published by

Hussain Nagaria

on

Feb, 16 2023
0

Share

Add your comment

Success!

Error

Comments

No comments, yet.

Discussion

image7f7d4e.png

Paul Mugambi

ยท

3 days

ago

Beautiful read, and an insight into an individual I respect and have learned a lot from. Am inspired to trust the process and never give up.

image4c43d6.png

Anna Dane

ยท

5 days

ago

I must say this is a really amazing post, and for some of my friends who provide Best British Assignment Help, I must recommend this post to them.

Add your comment

Comment