The Goal
In this three part blog series, we are going to build an online store for selling eBooks! Here is how it is going to work: users will be able to browse a list of ebooks, view the details of a particular ebook and purchase it. On a successful purchase, the user will receive the ebook file (PDF, ePUB) via Email.
In this blog, we will build the website using portal pages, Website Generator, and Bulma. In the next part, we will implement the payments and eBook delivery mechanism.
The Stack
We will build the store as a custom Frappe app. For styling, I am going with Bulma this time (miss you tailwind 😁). I really like Bulma's color schemes and minimal components.
For payments, we will use the Frappe Payments app.
Setting up our Frappe App
Prerequisites
This tutorial assumes you know the basics of Frappe Framework. You will need a working Frappe bench installation on your local machine to follow along. You can learn more about setting up bench here.
Create a new app
Let's create a new custom Frappe app using the below command (run inside bench directory):
bench new-app star_ebook_store
Create a new site
We can create a site named abc.localhost
:
bench new-site abc.localhost
Install the app on our site
Let's install our custom app on our newly created site:
bench --site abc.localhost install-app star_ebook_store
Make sure you enable developer mode and we are ready to go!
Everything Starts with a DocType
We will start by creating two DocTypes:
eBook
: Master DocType for storing all things related to an eBook. This DocType will have the following fields:- Name - "Set by User" (in naming rule)
- Cover Image - Attach Image
- Asset File - Attach (the deliverable asset file)
- Format - Select (PDF, ePUB)
- Price - Currency
- Author - Link (Author DocType)
- Description - Markdown Editor
- Table Of Contents - Markdown Editor
- Route - Data (for web view)
- Is Published - Check (for web view)
Author
: For storing information about book authors, will be linked to an eBook. For simplicity, we will assume exactly 1 author per eBook. This DocType will have just 2 fields:- Full Name - Data
- Bio - Markdown Editor
It became a breeze to build and layout the DocTypes using the new Form Builder contributed by my friend Sharique Ansari recently. Kudos!
For the eBook
DocType, make sure you check the "Has Web View" checkbox in the Web View section of the DocType form:
Also, make sure you set the Is Published field to is_published
. This will be used to track whether a page is published or not by Frappe Framework. Unpublished pages are not accessible to users.
We will also set the Image Field in Form Settings for eBook
to cover_image
. The final result is shown below:
Website Generator
When you check the "Has Web View" checkbox for a DocType, two notable things will happen:
- The
eBook
controller class will inherit from theWebsiteGenerator
class instead of the defaultDocument
class. - Two
html
template (jinja
) files will be generated under theebook/templates
directory:ebook.html
andebook_row.html
.
The ebook.html
file is rendered when the user visits the route defined in the route
field of the document. The ebook_row.html
file is rendered when you visit the route you set in the DocType form's Web View section.
Since, we are not going to use the ebook_row.html
to display the list, I have kept the Route empty in the eBook
DocType form. We will display the list of ebooks in a separate web page.
Let's focus on ebook.html
for now. Try to visit /store/hello
(the route we set in the "Never Split The Difference" ebook) above. A page will be rendered for you by default:
The content of the ebook.html
file is as follows:
{% extends "templates/web.html" %}
{% block page_content %}
<h1>{{ title }}</h1>
{% endblock %}
<!-- this is a sample default web page template -->
As you can see, this template extends another template file at "templates/web.html" which is located in the Frappe app's templates directory. This is the reason why we get the header, footer and the default bootstrap styling.
Building the eBook Details Page
Let's get rid of all the sample code and add in our own code:
{% extends "templates/store/base.html" %}
{% block body %}
<div class="columns">
<div class="column is-one-third">
<img class="cover-image px-6" src="{{ doc.cover_image }}" alt="Book Cover Image">
</div>
<div class="column">
<div>
<h1 class="title is-1">{{ doc.name }}</h1>
</div>
<div class="mt-3">
<span class="tag is-link is-normal">{{ doc.format }}</span>
</div>
</div>
</div>
{% endblock %}
Here, I am extending from "templates/store/base.html". Let's create this file and add in our base template code in the next section.
Setting up Templates & Bulma
I will create a new directory in our app root named templates/store
and add a file named base.html
in it. Add in the following contents:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<link rel="stylesheet" href="/assets/star_ebook_store/css/store.css">
<title>Star Ebook Store</title>
</head>
<body class="antialiased">
{%- block body -%} {%- endblock -%}
</body>
</html>
I have created this base template so that we can reuse this while building other pages of our store. The below link tag will import Bulma CSS:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
I have also added a CSS file to write our custom CSS rules (inside public/css/store.css
, content here)
Now, visit the /store/hello
route in your browser and you should see a Bulma styled page:
Some Extra Context
The eBook
document is available in the jinja context of ebook.html
template via the doc
variable. But, we need some extra data for displaying information about the Author. We can do this by writing a method named get_context
in our eBook
controller class (doctype/ebook/ebook.py
):
import frappe
from frappe.website.website_generator import WebsiteGenerator
class eBook(WebsiteGenerator):
def get_context(self, context):
context.author = frappe.db.get_value(
"Author", self.author, ["full_name as name", "bio"], as_dict=True
)
The context
is passed as a positional argument and we can attach extra stuff to it. This data will be available in the jinja template ebook.html
.
I am attaching the author dictionary fetched from database using frappe.get_value
. Now, in ebook.html
I can add the following line to render the author's name:
<h2 class="subtitle is-4">by {{ author.name }}</h2>
You can find the full templates in the source code repository here. Let's also render the markdown fields in our ebook page:
<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>
I am using the tabs
component from Bulma to display three tabs. We will get them working later, but for now, observe how we are using the frappe.utils.md_to_html
function to render markdown to html. Many such utilities are available in the frappe
namespace in these jinja
templates. You can refresh the page to see the updated content:
Notice that I have also added some breadcrumbs and a Download
button which links to the ebook.asset_file
for now. In the next blog, we will replace this with a working Buy Now
button.
Building the Store Landing Page
Now that we have completed the ebook details page, let's move to build our store's landing page. This page will have a hero section and a grid of all ebooks in our database.
We will start by creating a new file named store.html
inside our app's www
directory. This page will automatically be served at /store
on our site. If you want to learn more about portal pages using www
directory, visit this link for the docs.
Here are the contents of this page:
{% extends "templates/store/base.html" %}
{% block body %}
<section class="hero is-large is-link">
<div class="hero-body">
<p class="title">
Star eBook Store
</p>
<p class="subtitle">
Awesome collection of ebooks from all over the internet.
</p>
</div>
</section>
{% endblock %}
Since, we already have the base template in place, we can extend it easily. This way our templates are DRY. Now, visit <your-site-url>/store
and you should see a page identical to the one shown below:
It is pretty simple as of now, just the hero section. But we want to render a list of ebooks on this page. How do we insert dynamic content on this page? That's what we are going to do next!
Passing context
If you want to pass some data (context) that you want to use in your web page template (every file in www
is a jinja template), we have to pass that context from a Python file. In the case of a DocType web view, we wrote a get_context
method in the controller class (eBook
above).
But in case of portal pages in www
directory, we have to create a separate Python file to pass the context. The Python file should be of the same name as the HTML file and placed in the same directory:
We just need to create a function named get_context
(similar to what we did for website generator based web page) which receives the context we can add properties to.
In the above case, I am doing a get_all
to get a list of all published eBook
s with the required fields and adding it to the context. Now, we should be able to write the following code in our store.html
:
<main class="container is-max-widescreen">
<h3 class="title mt-5">Full Collection</h3>
<div class="columns my-6">
{% for ebook in ebooks %}
<div class="column is-one-fifth">
<a href="{{ ebook.route }}">
<div class="card">
<div class="card-image">
<figure class="card-cover-image">
<img src="{{ ebook.cover_image }}" alt="Cover image">
</figure>
</div>
<div class="card-content">
<div class="media">
<div class="media-content">
<p class="title is-4">{{ ebook.name }}</p>
<p class="subtitle is-6">by {{ ebook.author_name }}</p>
</div>
</div>
<div class="content">
445 downloads
</div>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
</main>
We are looping over the ebooks list and rendering a card (Bulma component) for each of them. Go ahead, refresh the store page:
Voilà! We have a dynamic page now.
Dynamically Generating the Route
If you recall, we added a route
field to the eBook
DocType. If you leave it empty, the framework will use the name of the document to generate a route automatically for you. For example, "creative-confidence", "never-split-the-difference" etc. So, the web views will be available at "your-site-url/creative-confidence" and so on. But I want it to be served at "your-site-url/store/book-name". Better than having the ebook pages in the root path in my opinion.
We can do this very easily by overriding the validate
lifecycle hook in our eBook
controller class:
from frappe.website.utils import cleanup_page_name
class eBook(WebsiteGenerator):
def validate(self):
if not self.route:
self.route = f"store/{cleanup_page_name(self.name)}"
... and done. You can now click on the ebook cards to visit the ebook details page. We have developed a beautiful store in record time!
Resources
- Source Code for this tutorial is on GitHub here
- Portal Pages in Frappe Framework: docs
- Web Views / Website Generator: docs
Conclusion
You can find the source code of the completed app here. I hope this post was helpful, let me know in the comments below!
We will continue from here in the next part, where we will add functionality for online payments and also auto-delivery of the purchased eBook to the user via Email. See you in that one!