feat: Shop by Category

- Added Shop by Category Page
- Tabbed sections for item fields in Shop by Category Page
- Added Shop by Category Section in E commerce Settings
- Nested Navigation & Breadcrumbs in Item group pages
- Added scrollable & clickable Sub categories in Item Group page
- Made breadcrumbs slightly dynamic in Item Page
- Added image to Brand doctype
This commit is contained in:
marination 2021-03-02 19:54:01 +05:30
parent 22f41a17b7
commit d7130e31fe
12 changed files with 356 additions and 268 deletions

View File

@ -38,7 +38,9 @@
"enable_field_filters", "enable_field_filters",
"filter_fields", "filter_fields",
"enable_attribute_filters", "enable_attribute_filters",
"filter_attributes" "filter_attributes",
"shop_by_category_section",
"slideshow"
], ],
"fields": [ "fields": [
{ {
@ -64,7 +66,7 @@
"collapsible": 1, "collapsible": 1,
"fieldname": "filter_categories_section", "fieldname": "filter_categories_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Filters" "label": "Filters and Categories"
}, },
{ {
"default": "0", "default": "0",
@ -78,9 +80,10 @@
}, },
{ {
"default": "0", "default": "0",
"description": "The field filters will also work as categories in the <b>Shop by Category</b> page.",
"fieldname": "enable_field_filters", "fieldname": "enable_field_filters",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enable Field Filters" "label": "Enable Field Filters (Categories)"
}, },
{ {
"default": "0", "default": "0",
@ -258,12 +261,25 @@
"label": "Payment Gateway Account", "label": "Payment Gateway Account",
"mandatory_depends_on": "enable_checkout", "mandatory_depends_on": "enable_checkout",
"options": "Payment Gateway Account" "options": "Payment Gateway Account"
},
{
"collapsible": 1,
"depends_on": "enable_field_filters",
"fieldname": "shop_by_category_section",
"fieldtype": "Section Break",
"label": "Shop by Category"
},
{
"fieldname": "slideshow",
"fieldtype": "Link",
"label": "Slideshow",
"options": "Website Slideshow"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2021-02-11 18:22:14.556880", "modified": "2021-03-01 20:24:56.548673",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "E-commerce", "module": "E-commerce",
"name": "E Commerce Settings", "name": "E Commerce Settings",

View File

@ -165,7 +165,7 @@ class WebsiteItem(WebsiteGenerator):
context.show_search = True context.show_search = True
context.search_link = '/search' context.search_link = '/search'
context.parents = get_parent_item_groups(self.item_group) context.parents = get_parent_item_groups(self.item_group, from_item=True)
context.body_class = "product-page" context.body_class = "product-page"
self.attributes = frappe.get_all("Item Variant Attribute", self.attributes = frappe.get_all("Item Variant Attribute",
fields=["attribute", "attribute_value"], fields=["attribute", "attribute_value"],

View File

@ -2,7 +2,6 @@ import frappe
from frappe.utils import cint from frappe.utils import cint
from erpnext.e_commerce.product_configurator.item_variants_cache import ItemVariantsCacheManager from erpnext.e_commerce.product_configurator.item_variants_cache import ItemVariantsCacheManager
from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website
def get_item_codes_by_attributes(attribute_filters, template_item_code=None): def get_item_codes_by_attributes(attribute_filters, template_item_code=None):
items = [] items = []

View File

@ -323,6 +323,32 @@ body.product-page {
} }
} }
.sub-category-container {
padding-bottom: 1rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--table-border-color);
.heading {
color: var(--gray-500);
}
}
.scroll-categories {
white-space: nowrap;
overflow-x: auto;
.category-pill {
margin: 0px 4px;
display: inline-block;
padding: 6px 12px;
background-color: #ecf5fe;
width: fit-content;
font-size: 14px;
border-radius: 18px;
color: var(--blue-500);
}
}
.cart-icon { .cart-icon {
.cart-badge { .cart-badge {
position: relative; position: relative;

View File

@ -1,270 +1,111 @@
{ {
"allow_copy": 0, "actions": [],
"allow_events_in_timeline": 0, "allow_import": 1,
"allow_guest_to_view": 0, "allow_rename": 1,
"allow_import": 1, "autoname": "field:brand",
"allow_rename": 1, "creation": "2013-02-22 01:27:54",
"autoname": "field:brand", "doctype": "DocType",
"beta": 0, "document_type": "Setup",
"creation": "2013-02-22 01:27:54", "engine": "InnoDB",
"custom": 0, "field_order": [
"docstatus": 0, "brand",
"doctype": "DocType", "image",
"document_type": "Setup", "description",
"editable_grid": 0, "defaults",
"brand_defaults"
],
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0, "allow_in_quick_entry": 1,
"allow_in_quick_entry": 1, "fieldname": "brand",
"allow_on_submit": 0, "fieldtype": "Data",
"bold": 0, "label": "Brand Name",
"collapsible": 0, "oldfieldname": "brand",
"columns": 0, "oldfieldtype": "Data",
"fieldname": "brand", "reqd": 1,
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Brand Name",
"length": 0,
"no_copy": 0,
"oldfieldname": "brand",
"oldfieldtype": "Data",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 1 "unique": 1
}, },
{ {
"allow_bulk_edit": 0, "fieldname": "description",
"allow_in_quick_entry": 0, "fieldtype": "Text",
"allow_on_submit": 0, "in_list_view": 1,
"bold": 0, "label": "Description",
"collapsible": 0, "oldfieldname": "description",
"columns": 0, "oldfieldtype": "Text",
"fieldname": "description",
"fieldtype": "Text",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Description",
"length": 0,
"no_copy": 0,
"oldfieldname": "description",
"oldfieldtype": "Text",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "300px" "width": "300px"
}, },
{ {
"allow_bulk_edit": 0, "fieldname": "defaults",
"allow_in_quick_entry": 0, "fieldtype": "Section Break",
"allow_on_submit": 0, "label": "Defaults"
"bold": 0, },
"collapsible": 0,
"columns": 0,
"fieldname": "defaults",
"fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Defaults",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "brand_defaults",
"allow_in_quick_entry": 0, "fieldtype": "Table",
"allow_on_submit": 0, "label": "Brand Defaults",
"bold": 0, "options": "Item Default"
"collapsible": 0, },
"columns": 0, {
"fieldname": "brand_defaults", "fieldname": "image",
"fieldtype": "Table", "fieldtype": "Attach Image",
"hidden": 0, "hidden": 1,
"ignore_user_permissions": 0, "label": "Image"
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Brand Defaults",
"length": 0,
"no_copy": 0,
"options": "Item Default",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
} }
], ],
"has_web_view": 0, "icon": "fa fa-certificate",
"hide_heading": 0, "idx": 1,
"hide_toolbar": 0, "image_field": "image",
"icon": "fa fa-certificate", "links": [],
"idx": 1, "modified": "2021-03-01 15:57:30.005783",
"image_view": 0, "modified_by": "Administrator",
"in_create": 0, "module": "Setup",
"is_submittable": 0, "name": "Brand",
"issingle": 0, "owner": "Administrator",
"istable": 0,
"max_attachments": 0,
"modified": "2018-10-23 23:18:06.067612",
"modified_by": "Administrator",
"module": "Setup",
"name": "Brand",
"owner": "Administrator",
"permissions": [ "permissions": [
{ {
"amend": 0, "create": 1,
"cancel": 0, "delete": 1,
"create": 1, "email": 1,
"delete": 1, "export": 1,
"email": 1, "import": 1,
"export": 1, "print": 1,
"if_owner": 0, "read": 1,
"import": 1, "report": 1,
"permlevel": 0, "role": "Item Manager",
"print": 1, "share": 1,
"read": 1,
"report": 1,
"role": "Item Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1 "write": 1
}, },
{ {
"amend": 0, "email": 1,
"cancel": 0, "print": 1,
"create": 0, "read": 1,
"delete": 0, "report": 1,
"email": 1, "role": "Stock User"
"export": 0, },
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Stock User",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"write": 0
},
{ {
"amend": 0, "email": 1,
"cancel": 0, "print": 1,
"create": 0, "read": 1,
"delete": 0, "report": 1,
"email": 1, "role": "Sales User"
"export": 0, },
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"write": 0
},
{ {
"amend": 0, "email": 1,
"cancel": 0, "print": 1,
"create": 0, "read": 1,
"delete": 0, "report": 1,
"email": 1, "role": "Purchase User"
"export": 0, },
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Purchase User",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"write": 0
},
{ {
"amend": 0, "email": 1,
"cancel": 0, "print": 1,
"create": 0, "read": 1,
"delete": 0, "report": 1,
"email": 1, "role": "Accounts User"
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"write": 0
} }
], ],
"quick_entry": 1, "quick_entry": 1,
"read_only": 0, "show_name_in_global_search": 1,
"read_only_onload": 0, "sort_field": "modified",
"show_name_in_global_search": 1, "sort_order": "ASC"
"sort_order": "ASC", }
"track_changes": 0,
"track_seen": 0,
"track_views": 0
}

View File

@ -88,8 +88,8 @@ class ItemGroup(NestedSet, WebsiteGenerator):
if not field_filters: if not field_filters:
field_filters = {} field_filters = {}
# Ensure the query remains within current item group & sub group # Ensure the query remains within current item group
field_filters['item_group'] = [ig[0] for ig in get_child_groups(self.name)] field_filters['item_group'] = self.name
engine = ProductQuery() engine = ProductQuery()
context.items = engine.query(attribute_filters, field_filters, search, start, item_group=self.name) context.items = engine.query(attribute_filters, field_filters, search, start, item_group=self.name)
@ -104,6 +104,7 @@ class ItemGroup(NestedSet, WebsiteGenerator):
"title": self.name "title": self.name
}) })
context.sub_categories = get_child_groups(self.name)
if self.slideshow: if self.slideshow:
values = { values = {
'show_indicators': 1, 'show_indicators': 1,
@ -123,8 +124,9 @@ class ItemGroup(NestedSet, WebsiteGenerator):
context.slideshow = values context.slideshow = values
context.breadcrumbs = 0 context.no_breadcrumbs = False
context.title = self.website_title or self.name context.title = self.website_title or self.name
context.body_class = "product-page"
return context return context
@ -136,10 +138,11 @@ class ItemGroup(NestedSet, WebsiteGenerator):
validate_item_default_company_links(self.item_group_defaults) validate_item_default_company_links(self.item_group_defaults)
def get_child_groups(item_group_name): def get_child_groups(item_group_name):
"""Returns child item groups *excluding* passed group."""
item_group = frappe.get_doc("Item Group", item_group_name) item_group = frappe.get_doc("Item Group", item_group_name)
return frappe.db.sql("""select name return frappe.db.sql("""select name, route
from `tabItem Group` where lft>=%(lft)s and rgt<=%(rgt)s from `tabItem Group` where lft>%(lft)s and rgt<%(rgt)s
and show_in_website = 1""", {"lft": item_group.lft, "rgt": item_group.rgt}) and show_in_website = 1""", {"lft": item_group.lft, "rgt": item_group.rgt}, as_dict=1)
def get_child_item_groups(item_group_name): def get_child_item_groups(item_group_name):
item_group = frappe.get_cached_value("Item Group", item_group = frappe.get_cached_value("Item Group",
@ -164,15 +167,25 @@ def get_item_for_list_in_html(context):
return frappe.get_template(products_template).render(context) return frappe.get_template(products_template).render(context)
def get_parent_item_groups(item_group_name): def get_parent_item_groups(item_group_name, from_item=False):
base_nav_page = {"name": frappe._("Shop by Category"), "route":"/shop-by-category"}
if from_item:
# base page after 'Home' will vary on Item page
last_page = frappe.request.environ["HTTP_REFERER"].split('/')[-1]
if last_page and last_page in ("shop-by-category", "all-products"):
base_nav_page_title = " ".join(last_page.split("-")).title()
base_nav_page = {"name": frappe._(base_nav_page_title), "route":"/"+last_page}
base_parents = [ base_parents = [
{"name": frappe._("Home"), "route":"/"}, {"name": frappe._("Home"), "route":"/"},
{"name": frappe._("All Products"), "route":"/all-products"}, base_nav_page,
] ]
if not item_group_name: if not item_group_name:
return base_parents return base_parents
item_group = frappe.get_doc("Item Group", item_group_name) item_group = frappe.db.get_value("Item Group", item_group_name, ["lft", "rgt"], as_dict=1)
parent_groups = frappe.db.sql("""select name, route from `tabItem Group` parent_groups = frappe.db.sql("""select name, route from `tabItem Group`
where lft <= %s and rgt >= %s where lft <= %s and rgt >= %s
and show_in_website=1 and show_in_website=1

View File

@ -8,6 +8,12 @@
<script type="text/javascript" src="/all-products/index.js"></script> <script type="text/javascript" src="/all-products/index.js"></script>
{% endblock %} {% endblock %}
{% block breadcrumbs %}
<div class="item-breadcrumbs small text-muted">
{% include "templates/includes/breadcrumbs.html" %}
</div>
{% endblock %}
{% block page_content %} {% block page_content %}
<div class="item-group-content" itemscope itemtype="http://schema.org/Product" data-item-group="{{ name }}"> <div class="item-group-content" itemscope itemtype="http://schema.org/Product" data-item-group="{{ name }}">
<div class="item-group-slideshow"> <div class="item-group-slideshow">
@ -27,6 +33,20 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-12 order-2 col-md-9 order-md-2 item-card-group-section"> <div class="col-12 order-2 col-md-9 order-md-2 item-card-group-section">
{% if sub_categories %}
<div class="sub-category-container">
<div class="heading"> {{ _('Sub Categories') }} </div>
</div>
<div class="sub-category-container scroll-categories">
{% for row in sub_categories%}
<a href="{{ row.route or '#' }}" style="text-decoration: none;">
<div class="category-pill">
{{ row.name }}
</div>
</a>
{% endfor %}
</div>
{% endif %}
<div class="row products-list"> <div class="row products-list">
{% if items %} {% if items %}
{% for item in items %} {% for item in items %}

View File

View File

@ -0,0 +1,28 @@
{%- macro card(title, image, type, url=None, text_primary=False) -%}
<!-- style defined at shop-by-category index -->
<div class="card category-card" data-type="{{ type }}" data-name="{{ title }}">
{% if image %}
<img class="card-img-top" src="{{ image }}" alt="{{ title }}" style="height:80%">
{% else %}
<div class="placeholder-div">
<span class="placeholder">AB</span>
</div>
{% endif %}
<div class="card-body text-center text-muted">
{{ title or '' }}
</div>
<a href="{{ url or '#' }}" class="stretched-link"></a>
</div>
{%- endmacro -%}
<div class="col-12 item-card-group-section">
<div class="row products-list product-category-section">
{%- for row in data -%}
{%- set title = row.name -%}
{%- set image = row.get("image") -%}
{%- if title -%}
{{ card(title, image, type, row.get("route")) }}
{%- endif -%}
{%- endfor -%}
</div>
</div>

View File

@ -0,0 +1,60 @@
{% extends "templates/web.html" %}
{% block title %}{{ _('Shop by Category') }}{% endblock %}
{% block head_include %}
<style>
.category-slideshow {
margin-bottom: 2rem;
}
.category-card {
height: 300px !important;
width: 300px !important;
margin: 30px !important;
}
.placeholder-div {
height:80%;
width: -webkit-fill-available;
padding: 50px;
text-align: center;
background-color: #F9FAFA;
border-top-left-radius: calc(0.75rem - 1px);
border-top-right-radius: calc(0.75rem - 1px);
}
.placeholder {
font-size: 72px;
}
</style>
{% endblock %}
{% block script %}
<script type="text/javascript" src="/shop-by-category/index.js"></script>
{% endblock %}
{% block page_content %}
<div class="shop-by-category-content">
<div class="category-slideshow">
{% if slideshow %}
<!-- slideshow -->
{{ web_block(
"Hero Slider",
values=slideshow,
add_container=0,
add_top_padding=0,
add_bottom_padding=0,
) }}
{% endif %}
</div>
<div class="category-tabs">
{% if tabs %}
<!-- tabs -->
{{ web_block(
"Section with Tabs",
values=tabs,
add_container=0,
add_top_padding=0,
add_bottom_padding=0
) }}
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,12 @@
$(() => {
$('.category-card').on('click', (e) => {
let category_type = e.currentTarget.dataset.type;
let category_name = e.currentTarget.dataset.name;
if (category_type != "item_group") {
let filters = {};
filters[category_type] = [category_name];
window.location.href = "/all-products?field_filters=" + JSON.stringify(filters);
}
});
});

View File

@ -0,0 +1,73 @@
import frappe
from frappe import _
sitemap = 1
def get_context(context):
settings = frappe.get_doc("E Commerce Settings")
context.categories_enabled = settings.enable_field_filters
if context.categories_enabled:
categories = [row.fieldname for row in settings.filter_fields]
context.tabs = get_tabs(categories)
if settings.slideshow:
context.slideshow = get_slideshow(settings.slideshow)
context.no_cache = 1
def get_slideshow(slideshow):
values = {
'show_indicators': 1,
'show_controls': 1,
'rounded': 1,
'slider_name': "Categories"
}
slideshow = frappe.get_doc("Website Slideshow", slideshow)
slides = slideshow.get({"doctype": "Website Slideshow Item"})
for index, slide in enumerate(slides):
values[f"slide_{index + 1}_image"] = slide.image
values[f"slide_{index + 1}_title"] = slide.heading
values[f"slide_{index + 1}_subtitle"] = slide.description
values[f"slide_{index + 1}_theme"] = slide.get("theme") or "Light"
values[f"slide_{index + 1}_content_align"] = slide.get("content_align") or "Centre"
values[f"slide_{index + 1}_primary_action"] = slide.url
return values
def get_tabs(categories):
tab_values = {
'title': _("Shop by Category"),
}
categorical_data = get_category_records(categories)
for index, tab in enumerate(categorical_data):
tab_values[f"tab_{index + 1}_title"] = frappe.unscrub(tab)
# pre-render cards for each tab
tab_values[f"tab_{index + 1}_content"] = frappe.render_template(
"erpnext/www/shop-by-category/category_card_section.html",
{"data": categorical_data[tab], "type": tab}
)
return tab_values
def get_category_records(categories):
categorical_data = {}
for category in categories:
if category == "item_group":
categorical_data["item_group"] = frappe.db.sql("""
Select name, parent_item_group, is_group, image, route
from `tabItem Group`
where parent_item_group='All Item Groups'
and show_in_website=1""", as_dict=1)
else:
doctype = frappe.unscrub(category)
fields = ["name"]
if frappe.get_meta(doctype, cached=True).get_field("image"):
fields += ["image"]
categorical_data[category] = frappe.db.sql("""
Select {fields}
from `tab{doctype}`""".format(doctype=doctype, fields=",".join(fields)), as_dict=1)
return categorical_data