In this step, you add the Acme home page and product page. Each page is a template, plus an optional Python controller that gets the data.
What you'll build
- A home page with a hero and the eight newest products
- A product card macro that the home page uses
- A product page with sizes and an Add to cart button
How a file becomes a URL
The Themed Routes table in Shop Theme Settings maps each URL to a file in pages/. Commera fills it when you install it:
| URL | File |
|---|---|
/en, /ar |
pages/index.html |
/en/products |
pages/products/list.html |
/en/products/<route> |
pages/products/details.html |
/en/cart |
pages/cart/cart.html |
The full table is in Routes and pages. Acme has only two pages. For every other URL, Commera looks in Shop Base Theme, which has no pages, and then serves its core page.
1. Add the home page controller
Create acme_theme/themes/acme/pages/index.py:
from commera.utils import get_product_list
from commera.www import index
def get_context(context):
index.get_context(context)
context.acme_tagline = "Everyday goods, honestly made."
context.latest_products = get_product_list(page_length=8, sort_by="new_arrival")
return context
The controller first calls Commera's own home page controller, commera.www.index.get_context. That call sets the SEO data and the home page settings. Then it adds the two values that the Acme template uses. sort_by="new_arrival" puts the newest products first. Without it, the list is in name order.
commera.www.index, commera.www.products.details and commera.utils.get_product_list are Commera's own page code, not a fixed API. Check your controllers after each Commera update.
A controller has these rules:
- Its name is the page's name with
.py, in the same folder as the page. - It defines
get_context(context). It can changecontext, or return a dict that Commera adds to the context. - It uses absolute imports only, such as
from commera.www import index.
2. Add a product card macro
Create acme_theme/themes/acme/components/macros/product_card.html:
{% macro product_card(product) %}
<a class="acme-card" href="/{{ frappe.lang or 'en' }}/products/{{ product.route }}">
<img src="{{ product.image or '/assets/commera/themes/shop_base_theme/images/placeholder.svg' }}" alt="{{ product.item_name }}" loading="lazy">
<span class="acme-card-title">{{ product.item_name }}</span>
<span class="acme-price">{{ get_currency_symbol() }}{{ frappe.utils.fmt_money(product.sale_price or 0, precision=2) }}</span>
</a>
{% endmacro %}
Shop Base Theme ships placeholder.svg for products without an image.
3. Add the home page template
Create acme_theme/themes/acme/pages/index.html:
{% extends "components/theme_layout.html" %}
{% from "components/macros/product_card.html" import product_card %}
{% block body %}
<section class="acme-hero">
<h1>{{ _("Welcome to {0}").format(app_name) }}</h1>
<p>{{ acme_tagline }}</p>
<a class="acme-button" href="/{{ frappe.lang or 'en' }}/products">{{ _("Shop all products") }}</a>
</section>
<section class="acme-section">
<h2>{{ _("Latest products") }}</h2>
<div class="acme-grid">
{% for product in latest_products %}
{{ product_card(product) }}
{% endfor %}
</div>
</section>
{% endblock %}
A page extends your layout and fills the body block. If a page adds to the head block, call {{ super() }} first, or the page loses the layout's stylesheets.
4. Add the product page
The product page uses Commera's product controller as it is. Create acme_theme/themes/acme/pages/products/details.py:
from commera.www.products import details
def get_context(context):
details.get_context(context)
return context
Then create acme_theme/themes/acme/pages/products/details.html:
{% extends "components/theme_layout.html" %}
{% block body %}
<section class="acme-product" x-data='{
product: JSON.parse({{ product_variant.as_json() | tojson }}),
selected_variant: {{ (json.dumps(selected_item) if size_selected else "") | tojson }},
sizes: {{ available_sizes | tojson }},
price: {{ (selected_price or 0) | tojson }},
default_price: {{ (default_price or 0) | tojson }},
brand: {{ (product.brand or "") | tojson }},
select_size(size) {
const url = new URL(window.location.href);
url.searchParams.set("size", size);
window.location.assign(url.toString());
},
add_to_cart() {
if (!this.selected_variant) {
toast($store.i18n.t("Please select a size first!"), { type: "warning" });
return;
}
$store.cart.add(this.product, this.selected_variant, this.price, this.default_price, this.brand, this.sizes, 1);
toast($store.i18n.t("Item added to your bag"), { type: "success" });
}
}'>
<img class="acme-product-image" src="{{ (images or [None])[0] or '/assets/commera/themes/shop_base_theme/images/placeholder.svg' }}" alt="{{ product_variant.display_name }}">
<div class="acme-product-info">
<h1>{{ product_variant.display_name }}</h1>
<p class="acme-price">{{ get_currency_symbol() }}{{ frappe.utils.fmt_money(selected_price or 0, precision=2) }}</p>
{% if available_sizes | length > 1 %}
<div class="acme-sizes">
{% for size in available_sizes %}
<button type="button" @click='select_size({{ size.size | tojson }})'
class="acme-size{% if size_selected and selected_size == size.size %} is-selected{% endif %}">{{ size.size }}</button>
{% endfor %}
</div>
{% endif %}
<button type="button" class="acme-button" @click="add_to_cart()" {% if not product_in_stock %}disabled{% endif %}>
{{ _("Add to cart") if product_in_stock else _("Out of stock") }}
</button>
</div>
</section>
{% endblock %}
The product controller reads the product from the URL and the size from ?size=. It sets product_variant, selected_item, available_sizes, selected_price, default_price and product_in_stock. A size change reloads the page, so the controller can find the price and stock of that size.
$store.cart.add() and toast() come from Shop Base Theme. $store.cart.add() parses the variant as JSON and throws an error on an empty value. The check in add_to_cart() stops that and shows a message.
Single-quote the x-data attribute. The tojson filter writes double quotes, and they would end a double-quoted attribute early.
Check it works
Open /en?preview_theme=Acme and /en/products/<a product route>?preview_theme=Acme. The pages have no styles yet. You add them in the next step. Preview and ship explains the preview parameter.
Changes to .html files show when you reload the page. Changes to a .py controller also show on reload, because Commera loads the file again when it changes.
Next steps
- Step 4: Add styles
- Routes and pages: the full route table and dynamic pages
- Templates: the context variables and Jinja helpers