Detailed Architecture
## How It Works
ls_shop is headless. All data lives in Jinja globals (shop_* functions registered via hooks.py).
Templates call these functions directly, there is no controller passing data to templates.
Request flow:
1. Request hits /en/products/item-019-red
2. ThemePageRenderer (page_renderer hook) checks: does active theme have pages/products/details.html?
YES → render theme template with ThemeFallbackLoader
NO → Frappe falls back to www/products/details.html (default)
3. Template calls shop_product_detail(product_route) to fetch data
4. Page renders with theme HTML + Tailwind
---
## Theme Folder Structure
ls_shop/themes/<theme_name>/
pages/
index.html → /en and /ar
products/
list.html → /en/products
details.html → /en/products/
All paths are optional. Missing → falls back to default.
---
## Template Resolution Order (ThemeFallbackLoader)
When a theme page renders, every include/extends goes through ThemeFallbackLoader:
1. theme_dir/<path> (exact match in theme root)
2. theme_dir/components/<path> (convenience: skip "components/" prefix)
3. Frappe default loader (templates/ directory, ls_shop defaults)
Examples:
{% include "templates/includes/header.html" %}
→ checks theme_dir/includes/header.html
→ checks theme_dir/components/includes/header.html ← finds it here if theme has it
→ falls back to ls_shop/templates/includes/header.html
{% include "templates/components/core/language_selector.html" %}
→ checks theme_dir/components/core/language_selector.html ← theme override
→ falls back to ls_shop/templates/components/core/language_selector.html
{% extends "templates/layout.html" %}
→ checks theme_dir/layout.html ← use this for a custom base layout
→ falls back to ls_shop/templates/layout.html
---
## Context Variables (available in every theme page)
These are set by the renderer before the template runs:
lang string "en" or "ar", current language
is_rtl bool True when lang is "ar"
csrf_token string CSRF token for forms
show_breadcrumb bool True by default
product_route string URL path segment after lang prefix
e.g. /en/products/item-019-red → "item-019-red"
Use this to call shop_product_detail(product_route)
boot dict Frappe boot data (site config, user info, etc.)
Everything else comes from shop_* Jinja calls, see below.
---
## Jinja API — shop_* Methods
All available as globals in every template. Call them directly, assign to variables.
### Products
shop_products(filters=None, product_list=None, page=1, page_length=30, sort_by="default")
Returns list of product variant dicts.
filters: dict with keys: subcategory, colors, sizes, brands, search, category,
has_discount, min_price, max_price
product_list: list of item_variant names (for curated lists)
sort_by: "new_arrival" | "price_low_high" | "price_high_low" | "default"
shop_product_count(filters=None, product_list=None)
Returns int, total count for pagination.
shop_product_filters(selected_filters=None)
Returns (filters_dict, price_range_dict)
filters_dict keys: brands, colors, sizes, subcategories
price_range_dict keys: min_price, max_price
shop_category_tree(root_category)
Returns nested category tree dict.
shop_product_detail(route, selected_size=None)
Returns full product detail dict:
product_variant , variant doc (display_name, attribute_value, route, etc.)
product , parent item doc (brand, item_name, custom_item_name_ar, etc.)
images , list of image URLs
in_stock , bool
available_sizes , list of {size, stock_detail: {in_stock, stock_qty}}
selected_size , currently selected size string
selected_item , the specific item doc for selected size
sale_price , float
default_price , float (original before discount)
discount_percent , float
recommended_items , list of related products
other_variants , list of other color variants {image, route, attribute_value}
size_chart , size chart data or None
item_qty , available quantity
shop_recommended_products(variant_name)
Returns list of related products.
### Homepage
shop_homepage()
Returns dict:
banners , list of banner dicts
new_arrivals , list of {item_variant} dicts
best_picks , list of {item_variant} dicts
sections , list of section dicts
### Cart / Checkout
shop_delivery_config()
Returns {shipping_amount, threshold}
shop_cod_config()
Returns {cod_charge_applicable_below, cod_charge}
shop_checkout()
Returns full checkout dict:
cart_quotation, items, billing_addresses, shipping_addresses,
store_pickup_addresses, delivery_charge, delivery_charge_applicable_below,
cod_charge, cod_charge_applicable_below, coupon_code,
show_telr, show_tabby, show_cod, payment_gateways, country_list
### Account
shop_user_details()
Returns User doc for current session user.
shop_addresses(address_type="Billing")
Returns list of address dicts. address_type: "Billing" | "Shipping"
shop_orders(page=1, page_length=6)
Returns {total_count, orders}
shop_order_detail(order_id)
Returns order dict or {}.
shop_return_reasons()
Returns list of {name, display_name}
### Utilities
shop_stock(item_code, warehouse=None)
Returns {stock_qty, in_stock}
shop_country_list()
Returns list of country names.
shop_discount_percent(default_price, sale_price)
Returns float discount percentage.
shop_store_pickup_addresses()
Returns list of store pickup address dicts.
shop_payment_gateways()
Returns list of enabled gateway names.
shop_theme_config()
Returns active theme's config dict (from iVend Theme DocType config field).
Use for theme settings: colors, feature toggles, etc.
shop_theme_asset_url(path)
Returns the URL for a static asset in the active theme's public folder.
Files go in: ls_shop/public/themes/<slug>/
Served at: /assets/ls_shop/themes/<slug>/
Example: shop_theme_asset_url('css/theme.css') → /assets/ls_shop/themes/my_theme/css/theme.css
Usage in template: <link rel="stylesheet" href="{{ shop_theme_asset_url('css/theme.css') }}">
---
## Jinja Filters
{{ price | money }}
Formats price with currency symbol. e.g. "₺ 22.00"
{{ image_path | img_url }}
Ensures valid image URL. Returns placeholder if empty.
{{ order | can_return }}
Returns bool, whether this order is eligible for return.
---
## URL Params in Templates
frappe.form_dict is available in templates for query string params:
frappe.form_dict.get("search", "")
frappe.form_dict.get("category", "")
frappe.form_dict.get("sort_by", "new_arrival")
frappe.form_dict.get("page", "1") | int
frappe.form_dict.get("order_id")
frappe.form_dict.get("size")
product_route (from context) gives the URL path segment, use it for product detail:
{% set detail = shop_product_detail(product_route) %}
---
## Default Templates to Reference
When building theme pages, reference these for logic and structure:
www/index.html → homepage
www/products/list.html → product listing
www/products/details.html → product detail
www/cart/cart.html → cart
www/cart/checkout.html → checkout
www/account/dashboard.html → account dashboard
www/account/orders/index.html → orders list
www/account/orders/detail.html → order detail
www/account/wishlist.html → wishlist
www/account/address.html → address management
www/account/profile.html → profile
Default components (all overridable):
templates/layout.html → base layout (extend this)
templates/includes/header.html → site header
templates/includes/footer.html → site footer
templates/includes/cart-state.html → Alpine cart store
templates/includes/wishlist-state.html → Alpine wishlist store
templates/includes/toast-state.html → Alpine toast store
templates/includes/i18-state.html → Alpine i18n store
templates/components/core/language_selector.html → language switcher
templates/components/core/search_bar.html → search
templates/components/core/pagination.html → pagination
templates/components/core/sort_by.html → sort dropdown
templates/components/core/country_selector.html → country picker
templates/components/product_details_carousel.html → product image carousel
templates/components/product_filter.html → filter sidebar
templates/macros/item_card.html → product card macro
templates/macros/icon.html → icon macro
templates/macros/modal.html → modal macro
templates/macros/tabby-promo.html → Tabby payment promo
---
## Common Patterns
### Minimal page skeleton
{% extends "templates/layout.html" %}
{% block body %}
<div class="mx-auto py-8">
{# your content here #}
</div>
{% endblock %}
### Product detail page
{% extends "templates/layout.html" %}
{% block body %}
{% set detail = shop_product_detail(product_route) %}
{% if not detail %}
<p>Product not found.</p>
{% else %}
<h1>{{ detail.product_variant.display_name }}</h1>
<p>{{ detail.sale_price | money }}</p>
{% if detail.discount_percent %}
<span>{{ detail.default_price | money }}</span>
<span>{{ detail.discount_percent | int }}% OFF</span>
{% endif %}
{# Sizes #}
{% for size in detail.available_sizes %}
<button>{{ size.size }}</button>
{% endfor %}
{# Images #}
{% for img in detail.images %}
<img src="{{ img | img_url }}">
{% endfor %}
{% endif %}
{% endblock %}
### Product listing page
{% extends "templates/layout.html" %}
{% block body %}
{% set page = frappe.form_dict.get("page", "1") | int %}
{% set search = frappe.form_dict.get("search", "") %}
{% set selected_filters = {
"search": search,
"category": frappe.form_dict.get("category", ""),
"has_discount": frappe.form_dict.get("has_discount", "0") == "1"
} %}
{% set products = shop_products(filters=selected_filters, page=page) %}
{% set total = shop_product_count(filters=selected_filters) %}
<div class="grid grid-cols-3 gap-4">
{% for p in products %}
<a href="/{{ lang }}/products/{{ p.route }}">
<img src="{{ p.image | img_url }}">
<p>{{ p.brand }}</p>
<p>{{ p.sale_price | money }}</p>
</a>
{% endfor %}
</div>
{% endblock %}
### Homepage
{% extends "templates/layout.html" %}
{% block body %}
{% set hp = shop_homepage() %}
{% set new_arrivals_ids = hp.new_arrivals | map(attribute="item_variant") | list %}
{% set products = shop_products(product_list=new_arrivals_ids, page_length=new_arrivals_ids | length or 6) %}
{% for banner in hp.banners %}
<img src="{{ banner.image | img_url }}">
{% endfor %}
<div class="grid grid-cols-4 gap-4">
{% for p in products %}
<a href="/{{ lang }}/products/{{ p.route }}">{{ p.sale_price | money }}</a>
{% endfor %}
</div>
{% endblock %}
### Custom page (no registration needed)
Create: themes/<name>/pages/about.html
Access: /en/about
{% extends "templates/layout.html" %}
{% block body %}
<h1>About Us</h1>
{% set config = shop_theme_config() %}
{% if config.show_social_links %}
{% include "templates/includes/social_links.html" %}
{% endif %}
{% endblock %}
### Overriding a component
Create: themes/<name>/components/core/language_selector.html
→ Automatically used everywhere templates/components/core/language_selector.html is included.
No registration. No changes to other files.
### Alpine.js stores (available globally)
$store.cart.add(product, variant, price, default_price, brand, sizes, qty)
$store.cart.items → current cart items
$store.cart.total → cart total
$store.wishlist.toggle(item_code)
$store.wishlist.has(item_code)
$store.i18n.t("key") → translated string
toast("message", { type: "success" | "warning" | "error" })
### RTL support
{% if is_rtl %}
{# use dir="rtl" attributes, flip flex directions, use ms-/me- instead of ml-/mr- #}
{% endif %}
All Tailwind directional utilities use logical properties:
ms-* = margin-start (left in LTR, right in RTL)
me-* = margin-end
### frappe.call (API calls from templates/JS)
Use frappe_call() (defined in frappe-call-script.html, included via layout.html):
const data = await frappe_call("/api/v2/method/ls_shop.api.some_method", { param: value })
---
## Creating a Theme
### Via CLI (recommended for development)
bench --site <site> create-theme "My Theme"
Creates:
ls_shop/themes/my_theme/ — template files
ls_shop/public/themes/my_theme/ — static assets (css/, js/, images/)
Then register in Desk → iVend Theme and set is_active = 1.
### Via Desk
Go to Desk → iVend Theme → New. Same folders are scaffolded on save.
---
## Theme Static Assets
Put CSS/JS/images in: ls_shop/public/themes/<slug>/
They are served at: /assets/ls_shop/themes/<slug>/
In templates use shop_theme_asset_url(), it resolves the path for the active theme:
{% block head %}
<link rel="stylesheet" href="{{ shop_theme_asset_url('css/theme.css') }}">
{% endblock %}
{% block script %}
<script src="{{ shop_theme_asset_url('js/theme.js') }}"></script>
{% endblock %}
After adding files to public/, run bench build to make them available.
---
## Theme Registration
1. Go to Desk → iVend Theme → New
2. Set theme_name (must match folder name under ls_shop/themes/)
3. Check is_active → deactivates any previously active theme
4. config field: JSON for theme settings (colors, feature flags, etc.)
Access in templates: {% set cfg = shop_theme_config() %}
---
## Auth / Protected Routes
Routes under /en/account/ and /en/cart/checkout automatically require login.
The renderer raises PermissionError for Guest users on these paths.
No need to check auth in templates for these routes.
For any other page that needs auth:
{% if frappe.session.user == "Guest" %}
<p>Please <a href="/en/login">log in</a>.</p>
{% else %}
{# authenticated content #}
{% endif %}
---
## What NOT to Do
- Do NOT add frappe.db calls or Python imports in templates. Use only shop_*() Jinja methods.