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:
- Part I: Storefront
- Part II: Payment & Delivery
- 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:
- User gets navigated to a new page with a success message
- 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:
- Create a new directory named
store
inside thewww
directory - Create 3 new files inside this directory:
index.html
,index.py
and__init__.py
- Copy the contents of
store.html
tostore/index.html
and copy the contents ofstore.py
tostore/index.py
- Feel free to delete the
store.html
andstore.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:
- 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 itfix_route.py
, because it will fix the routes. - Define a function named
execute
in this file. The function MUST be calledexecute
. 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)
- 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:
- A separate database call to get the sales counts and then joining the data with the ebook details.
- 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:
- Visit the
Benches
dashboard using the navigation and click on the+ New
button:
- 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:
- Visit the
Apps
tab in your private bench dashboard and click onAdd App
button:
- Scroll to the bottom and click on the
Add from GitHub
link:
- 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:
- A Shopping Cart for the store and ordering multiple eBooks at a time
- Better handling of errors in the frontend with error messages etc.
- Create reports for summarising eBook sales happening on your store
Resources
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, ๐๐๐!