diff --git a/erpnext/portal/doctype/products_settings/products_settings.py b/erpnext/portal/doctype/products_settings/products_settings.py index ae7dc68020..c1da3fed38 100644 --- a/erpnext/portal/doctype/products_settings/products_settings.py +++ b/erpnext/portal/doctype/products_settings/products_settings.py @@ -17,6 +17,7 @@ class ProductsSettings(Document): self.validate_field_filters() self.validate_attribute_filters() + frappe.clear_document_cache("Product Settings") def validate_field_filters(self): if not (self.enable_field_filters and self.filter_fields): return diff --git a/erpnext/portal/product_configurator/utils.py b/erpnext/portal/product_configurator/utils.py index 9ba4cdc514..4693d44509 100644 --- a/erpnext/portal/product_configurator/utils.py +++ b/erpnext/portal/product_configurator/utils.py @@ -1,6 +1,7 @@ import frappe from frappe.utils import cint from erpnext.portal.product_configurator.item_variants_cache import ItemVariantsCacheManager +from erpnext.shopping_cart.product_info import get_product_info_for_website def get_field_filter_data(): product_settings = get_product_settings() @@ -356,10 +357,10 @@ def get_items(filters=None, search=None): results = frappe.db.sql(''' SELECT - `tabItem`.`name`, `tabItem`.`item_name`, + `tabItem`.`name`, `tabItem`.`item_name`, `tabItem`.`item_code`, `tabItem`.`website_image`, `tabItem`.`image`, `tabItem`.`web_long_description`, `tabItem`.`description`, - `tabItem`.`route` + `tabItem`.`route`, `tabItem`.`item_group` FROM `tabItem` {left_join} @@ -384,6 +385,8 @@ def get_items(filters=None, search=None): for r in results: r.description = r.web_long_description or r.description r.image = r.website_image or r.image + product_info = get_product_info_for_website(r.item_code, skip_quotation_creation=True).get('product_info') + r.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None return results diff --git a/erpnext/public/build.json b/erpnext/public/build.json index d30bc8c144..b4a1cf81be 100644 --- a/erpnext/public/build.json +++ b/erpnext/public/build.json @@ -13,7 +13,8 @@ "public/js/shopping_cart.js" ], "css/erpnext-web.css": [ - "public/scss/website.scss" + "public/scss/website.scss", + "public/scss/shopping_cart.scss" ], "js/marketplace.min.js": [ "public/js/hub/marketplace.js" diff --git a/erpnext/public/images/ui-states/cart-empty-state.png b/erpnext/public/images/ui-states/cart-empty-state.png new file mode 100644 index 0000000000..e1ead0e175 Binary files /dev/null and b/erpnext/public/images/ui-states/cart-empty-state.png differ diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss new file mode 100644 index 0000000000..7bce6abafa --- /dev/null +++ b/erpnext/public/scss/shopping_cart.scss @@ -0,0 +1,490 @@ +@import "frappe/public/scss/desk/variables"; +@import "frappe/public/scss/common/mixins"; + +body.product-page { + background: var(--gray-50); +} + + +.item-breadcrumbs { + .breadcrumb-container { + ol.breadcrumb { + background-color: var(--gray-50) !important; + } + + a { + color: var(--gray-900); + } + } +} + +.carousel-control { + height: 42px; + width: 42px; + display: flex; + align-items: center; + justify-content: center; + background: white; + box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.08), 0px 1px 2px 1px rgba(0, 0, 0, 0.06); + border-radius: 100px; +} + +.carousel-control-prev, +.carousel-control-next { + opacity: 1; +} + +.carousel-body { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.carousel-content { + max-width: 400px; +} + +.card { + border: none; +} + +.product-category-section { + .card:hover { + box-shadow: 0px 16px 45px 6px rgba(0, 0, 0, 0.08), 0px 8px 10px -10px rgba(0, 0, 0, 0.04); + } + + .card-grid { + display: grid; + grid-gap: 15px; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));; + } +} + +.item-card-group-section { + .card { + height: 360px; + align-items: center; + justify-content: center; + + &:hover { + box-shadow: 0px 16px 60px rgba(0, 0, 0, 0.08), 0px 8px 30px -20px rgba(0, 0, 0, 0.04); + transition: box-shadow 400ms; + } + } + + // .card-body { + // text-align: center; + // } + + // .featured-item { + // .card-body { + // text-align: left; + // } + // } + + .card-img { + max-height: 210px; + object-fit: contain; + margin-top: 1.25rem; + } + + .no-image { + @include flex(flex, center, center, null); + height: 200px; + margin: 0 auto; + margin-top: var(--margin-xl); + background: var(--gray-100); + width: 80%; + border-radius: var(--border-radius); + font-size: 2rem; + color: var(--gray-500); + } + + .product-title { + font-size: 14px; + color: var(--gray-800); + font-weight: 500; + } + + .product-description { + font-size: 12px; + color: var(--text-color); + margin: 20px 0; + display: -webkit-box; + -webkit-line-clamp: 6; + -webkit-box-orient: vertical; + + p { + margin-bottom: 0.5rem; + } + } + + .product-category { + font-size: 13px; + color: var(--text-muted); + margin: var(--margin-sm) 0; + } + + .product-price { + font-size: 18px; + font-weight: 600; + color: var(--text-color); + margin: var(--margin-sm) 0; + } + + .item-card { + padding: var(--padding-sm); + } +} + +[data-doctype="Item Group"], +#page-all-products { + .page-header { + font-size: 20px; + font-weight: 700; + color: var(--text-color); + } + + .filters-section { + .title-section { + border-bottom: 1px solid var(--table-border-color); + } + + .filter-title { + font-weight: 500; + } + + .clear-filters { + font-size: 13px; + } + + .filter-label { + font-size: 11px; + font-weight: 600; + color: var(--gray-700); + text-transform: uppercase; + } + + .filter-block { + border-bottom: 1px solid var(--table-border-color); + } + + .checkbox { + .label-area { + font-size: 13px; + color: var(--gray-800); + } + } + } +} + +.product-container { + @include card($padding: var(--padding-md)); + min-height: 70vh; + + .product-details { + max-width: 40%; + margin-left: -30px; + + .btn-add-to-cart { + font-size: var(--text-base); + } + } + + .product-title { + font-size: 24px; + font-weight: 600; + color: var(--text-color); + } + + .product-code { + color: var(--text-muted); + font-size: 13px; + } + + .product-description { + font-size: 13px; + color: var(--gray-800); + } + + .product-image { + border-color: var(--table-border-color) !important; + padding: 15px; + + @include media-breakpoint-between(xs, md) { + height: 300px; + width: 300px; + } + + @include media-breakpoint-up(lg) { + height: 350px; + width: 350px; + } + + img { + object-fit: contain; + } + } + + .item-slideshow { + @include media-breakpoint-between(xs, md) { + max-height: 320px; + } + + @include media-breakpoint-up(lg) { + max-height: 430px; + } + + overflow: scroll; + } + + .item-slideshow-image { + height: 4rem; + width: 6rem; + object-fit: contain; + padding: 0.5rem; + border: 1px solid var(--table-border-color); + border-radius: 4px; + cursor: pointer; + + &:hover, &.active { + border-color: $primary; + } + } + + .item-cart { + .product-price { + font-size: 20px; + color: var(--text-color); + font-weight: 600; + + .formatted-price { + color: var(--text-muted); + font-size: var(--text-base); + } + } + + .no-stock { + font-size: var(--text-base); + } + } +} + +.item-configurator-dialog { + .modal-header { + padding: var(--padding-md) var(--padding-xl); + } + + .modal-body { + padding: 0 var(--padding-xl); + padding-bottom: var(--padding-xl); + + .status-area { + .alert { + padding: var(--padding-xs) var(--padding-sm); + font-size: var(--text-sm); + } + } + + .form-layout { + max-height: 50vh; + overflow-y: auto; + } + + .section-body { + .form-column { + .form-group { + .control-label { + font-size: var(--text-md); + color: var(--gray-700); + } + + .help-box { + margin-top: 2px; + font-size: var(--text-sm); + } + } + } + } + + svg { + display: none; + } + } +} + +.item-group-slideshow { + .item-group-description { + // max-width: 900px; + } + + .carousel-inner.rounded-carousel { + border-radius: $card-border-radius; + } +} + +.cart-icon { + .cart-badge { + position: relative; + top: -10px; + left: -12px; + background: var(--red-600); + width: 16px; + align-items: center; + height: 16px; + font-size: 10px; + border-radius: 50%; + } +} + + +#page-cart { + .shopping-cart-header { + font-weight: bold; + } + + .cart-container { + color: var(--text-color); + + .frappe-card { + display: flex; + flex-direction: column; + justify-content: space-between; + } + + .cart-items-header { + font-weight: 600; + } + + .cart-table { + th, tr, td { + border-color: var(--border-color); + border-width: 1px; + } + + th { + font-weight: normal; + font-size: 13px; + color: var(--text-muted); + padding: var(--padding-sm) 0; + } + + td { + padding: var(--padding-sm) 0; + color: var(--text-color); + } + + .cart-items { + .item-title { + font-size: var(--text-base); + font-weight: 500; + color: var(--text-color); + } + + .item-subtitle { + color: var(--text-muted); + font-size: var(--text-md); + } + + .item-subtotal { + font-size: var(--text-base); + font-weight: 500; + } + + .item-rate { + font-size: var(--text-md); + color: var(--text-muted); + } + + textarea { + width: 40%; + } + } + + .cart-tax-items { + .item-grand-total { + font-size: 16px; + font-weight: 600; + color: var(--text-color); + } + } + } + + .cart-addresses { + hr { + border-color: var(--border-color); + } + } + + .number-spinner { + width: 75%; + .cart-btn { + border: none; + background: var(--gray-100); + box-shadow: none; + height: 28px; + align-items: center; + display: flex; + } + + .cart-qty { + height: 28px; + font-size: var(--text-md); + } + } + + .place-order-container { + .btn-place-order { + width: 62%; + } + } + } +} + +.cart-empty.frappe-card { + min-height: 76vh; + @include flex(flex, center, center, column); + + .cart-empty-message { + font-size: 18px; + color: var(--text-color); + font-weight: bold; + } +} + +.address-card { + .card-title { + font-size: var(--text-base); + font-weight: 500; + } + + .card-text { + font-size: var(--text-md); + color: var(--gray-700); + } + + .card-link { + font-size: var(--text-md); + + svg use { + stroke: var(--blue-500); + } + } + + .btn-change-address { + color: var(--blue-500); + box-shadow: none; + border: 1px solid var(--blue-500); + } +} + +.modal .address-card { + .card-body { + padding: var(--padding-sm); + border-radius: var(--border-radius); + border: 1px solid var(--dark-border-color); + } +} + diff --git a/erpnext/public/scss/website.scss b/erpnext/public/scss/website.scss index 24a1b3780c..56b717c424 100644 --- a/erpnext/public/scss/website.scss +++ b/erpnext/public/scss/website.scss @@ -1,29 +1,10 @@ @import "frappe/public/scss/website/variables"; -.product-image img { - min-height: 20rem; - max-height: 30rem; -} - .filter-options { max-height: 300px; overflow: auto; } -.item-slideshow-image { - height: 3rem; - width: 3rem; - object-fit: contain; - padding: 0.5rem; - border: 1px solid $border-color; - border-radius: 4px; - cursor: pointer; - - &:hover, &.active { - border-color: $primary; - } -} - .address-card { cursor: pointer; position: relative; @@ -43,10 +24,10 @@ .check { display: inline-flex; - padding: 0.25rem; - background: $primary; - color: white; - border-radius: 50%; + padding: 0.25rem; + background: $primary; + color: white; + border-radius: 50%; font-size: 12px; width: 24px; height: 24px; diff --git a/erpnext/setup/doctype/item_group/item_group.js b/erpnext/setup/doctype/item_group/item_group.js index 9892dc3dcc..1413cb2862 100644 --- a/erpnext/setup/doctype/item_group/item_group.js +++ b/erpnext/setup/doctype/item_group/item_group.js @@ -61,6 +61,19 @@ frappe.ui.form.on("Item Group", { frappe.set_route("List", "Item", {"item_group": frm.doc.name}); }); } + + frappe.model.with_doctype('Item', () => { + const item_meta = frappe.get_meta('Item'); + + const valid_fields = item_meta.fields.filter( + df => ['Link', 'Table MultiSelect'].includes(df.fieldtype) && !df.hidden + ).map(df => ({ label: df.label, value: df.fieldname })); + + const field = frappe.meta.get_docfield("Website Filter Field", "fieldname", frm.docname); + field.fieldtype = 'Select'; + field.options = valid_fields; + frm.fields_dict.filter_fields.grid.refresh(); + }); }, set_root_readonly: function(frm) { diff --git a/erpnext/setup/doctype/item_group/item_group.json b/erpnext/setup/doctype/item_group/item_group.json index 004421d2bc..31624edb49 100644 --- a/erpnext/setup/doctype/item_group/item_group.json +++ b/erpnext/setup/doctype/item_group/item_group.json @@ -24,8 +24,12 @@ "route", "weightage", "slideshow", + "website_title", "description", "website_specifications", + "website_filters_section", + "filter_fields", + "filter_attributes", "lft", "rgt", "old_parent" @@ -180,6 +184,28 @@ "options": "Item Group", "print_hide": 1, "report_hide": 1 + }, + { + "fieldname": "website_filters_section", + "fieldtype": "Section Break", + "label": "Website Filters" + }, + { + "fieldname": "filter_fields", + "fieldtype": "Table", + "label": "Item Fields", + "options": "Website Filter Field" + }, + { + "fieldname": "filter_attributes", + "fieldtype": "Table", + "label": "Attributes", + "options": "Website Attribute" + }, + { + "fieldname": "website_title", + "fieldtype": "Data", + "label": "Title" } ], "icon": "fa fa-sitemap", @@ -188,7 +214,7 @@ "is_tree": 1, "links": [], "max_attachments": 3, - "modified": "2020-03-18 18:10:34.383363", + "modified": "2020-12-30 12:57:38.876956", "modified_by": "Administrator", "module": "Setup", "name": "Item Group", diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index 43778404b6..bff806d547 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -13,13 +13,16 @@ from frappe.website.doctype.website_slideshow.website_slideshow import get_slide from erpnext.shopping_cart.product_info import set_product_info_for_website from erpnext.utilities.product import get_qty_in_stock from six.moves.urllib.parse import quote +from erpnext.shopping_cart.product_query import ProductQuery +from erpnext.shopping_cart.filters import ProductFiltersBuilder class ItemGroup(NestedSet, WebsiteGenerator): nsm_parent_field = 'parent_item_group' website = frappe._dict( condition_field = "show_in_website", template = "templates/generators/item_group.html", - no_cache = 1 + no_cache = 1, + no_breadcrumbs = 1 ) def autoname(self): @@ -70,18 +73,58 @@ class ItemGroup(NestedSet, WebsiteGenerator): context.page_length = cint(frappe.db.get_single_value('Products Settings', 'products_per_page')) or 6 context.search_link = '/product_search' - start = int(frappe.form_dict.start or 0) - if start < 0: + if frappe.form_dict: + search = frappe.form_dict.search + field_filters = frappe.parse_json(frappe.form_dict.field_filters) + attribute_filters = frappe.parse_json(frappe.form_dict.attribute_filters) + start = frappe.parse_json(frappe.form_dict.start) + else: + search = None + attribute_filters = None + field_filters = {} start = 0 + + if not field_filters: + field_filters = {} + + # Ensure the query remains within current item group + field_filters['item_group'] = self.name + + engine = ProductQuery() + context.items = engine.query(attribute_filters, field_filters, search, start) + + filter_engine = ProductFiltersBuilder(self.name) + + context.field_filters = filter_engine.get_field_filters() + context.attribute_filters = filter_engine.get_attribute_fitlers() + context.update({ - "items": get_product_list_for_group(product_group = self.name, start=start, - limit=context.page_length + 1, search=frappe.form_dict.get("search")), "parents": get_parent_item_groups(self.parent_item_group), "title": self.name }) if self.slideshow: - context.update(get_slideshow(self)) + values = { + 'show_indicators': 1, + 'show_controls': 0, + 'rounded': 1, + 'slider_name': self.slideshow + } + slideshow = frappe.get_doc("Website Slideshow", self.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.theme or "Light" + values[f"slide_{index + 1}_content_align"] = slide.content_align or "Centre" + values[f"slide_{index + 1}_primary_action_label"] = slide.label + values[f"slide_{index + 1}_primary_action"] = slide.url + + context.slideshow = values + + context.breadcrumbs = 0 + context.title = self.website_title or self.name return context diff --git a/erpnext/shopping_cart/cart.py b/erpnext/shopping_cart/cart.py index c2549fe7dd..fa9dcedc64 100644 --- a/erpnext/shopping_cart/cart.py +++ b/erpnext/shopping_cart/cart.py @@ -42,14 +42,30 @@ def get_cart_quotation(doc=None): return { "doc": decorate_quotation_doc(doc), - "shipping_addresses": [{"name": address.name, "title": address.address_title, "display": address.display} - for address in addresses if address.address_type == "Shipping"], - "billing_addresses": [{"name": address.name, "title": address.address_title, "display": address.display} - for address in addresses if address.address_type == "Billing"], + "shipping_addresses": get_shipping_addresses(party), + "billing_addresses": get_billing_addresses(party), "shipping_rules": get_applicable_shipping_rules(party), "cart_settings": frappe.get_cached_doc("Shopping Cart Settings") } +@frappe.whitelist() +def get_shipping_addresses(party=None): + if not party: + party = get_party() + addresses = get_address_docs(party=party) + return [{"name": address.name, "title": address.address_title, "display": address.display} + for address in addresses if address.address_type == "Shipping" + ] + +@frappe.whitelist() +def get_billing_addresses(party=None): + if not party: + party = get_party() + addresses = get_address_docs(party=party) + return [{"name": address.name, "title": address.address_title, "display": address.display} + for address in addresses if address.address_type == "Billing" + ] + @frappe.whitelist() def place_order(): quotation = _get_cart_quotation() @@ -203,27 +219,33 @@ def get_terms_and_conditions(terms_name): @frappe.whitelist() def update_cart_address(address_type, address_name): quotation = _get_cart_quotation() - address_display = get_address_display(frappe.get_doc("Address", address_name).as_dict()) + address_doc = frappe.get_doc("Address", address_name).as_dict() + address_display = get_address_display(address_doc) if address_type.lower() == "billing": quotation.customer_address = address_name quotation.address_display = address_display quotation.shipping_address_name == quotation.shipping_address_name or address_name + address_doc = next((doc for doc in get_billing_addresses() if doc["name"] == address_name), None) elif address_type.lower() == "shipping": quotation.shipping_address_name = address_name quotation.shipping_address = address_display quotation.customer_address == quotation.customer_address or address_name - + address_doc = next((doc for doc in get_shipping_addresses() if doc["name"] == address_name), None) apply_cart_settings(quotation=quotation) quotation.flags.ignore_permissions = True quotation.save() context = get_cart_quotation(quotation) + context['address'] = address_doc + return { "taxes": frappe.render_template("templates/includes/order/order_taxes.html", context), - } + "address": frappe.render_template("templates/includes/cart/address_card.html", + context) + } def guess_territory(): territory = None diff --git a/erpnext/shopping_cart/filters.py b/erpnext/shopping_cart/filters.py new file mode 100644 index 0000000000..6c63d8759b --- /dev/null +++ b/erpnext/shopping_cart/filters.py @@ -0,0 +1,82 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _dict + +class ProductFiltersBuilder: + def __init__(self, item_group=None): + if not item_group or item_group == "Products Settings": + self.doc = frappe.get_doc("Products Settings") + else: + self.doc = frappe.get_doc("Item Group", item_group) + + self.item_group = item_group + + def get_field_filters(self): + filter_fields = [row.fieldname for row in self.doc.filter_fields] + + meta = frappe.get_meta('Item') + fields = [df for df in meta.fields if df.fieldname in filter_fields] + + filter_data = [] + for df in fields: + filters = {} + if df.fieldtype == "Link": + if self.item_group: + filters['item_group'] = self.item_group + + values = frappe.get_all("Item", fields=[df.fieldname], filters=filters, distinct="True", pluck=df.fieldname) + else: + doctype = df.get_link_doctype() + + # apply enable/disable/show_in_website filter + meta = frappe.get_meta(doctype) + + if meta.has_field('enabled'): + filters['enabled'] = 1 + if meta.has_field('disabled'): + filters['disabled'] = 0 + if meta.has_field('show_in_website'): + filters['show_in_website'] = 1 + + values = [d.name for d in frappe.get_all(doctype, filters)] + + # Remove None + values = values.remove(None) if None in values else values + if values: + filter_data.append([df, values]) + + return filter_data + + def get_attribute_fitlers(self): + attributes = [row.attribute for row in self.doc.filter_attributes] + attribute_docs = [ + frappe.get_doc('Item Attribute', attribute) for attribute in attributes + ] + + valid_attributes = [] + + for attr_doc in attribute_docs: + selected_attributes = [] + for attr in attr_doc.item_attribute_values: + filters= [ + ["Item Variant Attribute", "attribute", "=", attr.parent], + ["Item Variant Attribute", "attribute_value", "=", attr.attribute_value] + ] + if self.item_group: + filters.append(["item_group", "=", self.item_group]) + + if frappe.db.get_all("Item", filters, limit=1): + selected_attributes.append(attr) + + if selected_attributes: + valid_attributes.append( + _dict( + item_attribute_values=selected_attributes, + name=attr_doc.name + ) + ) + + return valid_attributes diff --git a/erpnext/shopping_cart/product_query.py b/erpnext/shopping_cart/product_query.py new file mode 100644 index 0000000000..da9e798327 --- /dev/null +++ b/erpnext/shopping_cart/product_query.py @@ -0,0 +1,120 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from erpnext.shopping_cart.product_info import get_product_info_for_website + +class ProductQuery: + """Query engine for product listing + + Attributes: + cart_settings (Document): Settings for Cart + fields (list): Fields to fetch in query + filters (TYPE): Description + or_filters (list): Description + page_length (Int): Length of page for the query + settings (Document): Products Settings DocType + filters (list) + or_filters (list) + """ + + def __init__(self): + self.settings = frappe.get_doc("Products Settings") + self.cart_settings = frappe.get_doc("Shopping Cart Settings") + self.page_length = self.settings.products_per_page or 20 + self.fields = ['name', 'item_name', 'item_code', 'website_image', 'variant_of', 'has_variants', 'item_group', 'image', 'web_long_description', 'description', 'route'] + self.filters = [['show_in_website', '=', 1]] + self.or_filters = [] + + def query(self, attributes=None, fields=None, search_term=None, start=0): + """Summary + + Args: + attributes (dict, optional): Item Attribute filters + fields (dict, optional): Field level filters + search_term (str, optional): Search term to lookup + start (int, optional): Page start + + Returns: + list: List of results with set fields + """ + if fields: self.build_fields_filters(fields) + if search_term: self.build_search_filters(search_term) + + result = [] + + if attributes: + all_items = [] + for attribute, values in attributes.items(): + if not isinstance(values, list): + values = [values] + + items = frappe.get_all( + "Item", + fields=self.fields, + filters=[ + *self.filters, + ["Item Variant Attribute", "attribute", "=", attribute], + ["Item Variant Attribute", "attribute_value", "in", values], + ], + or_filters=self.or_filters, + start=start, + limit=self.page_length + ) + + items_dict = {item.name: item for item in items} + # TODO: Replace Variants by their parent templates + + all_items.append(set(items_dict.keys())) + + result = [items_dict.get(item) for item in list(set.intersection(*all_items))] + else: + result = frappe.get_all("Item", fields=self.fields, filters=self.filters, or_filters=self.or_filters, start=start, limit=self.page_length) + + for item in result: + product_info = get_product_info_for_website(item.item_code, skip_quotation_creation=True).get('product_info') + item.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None + + return result + + def build_fields_filters(self, filters): + """Build filters for field values + + Args: + filters (dict): Filters + """ + for field, values in filters.items(): + if not values: + continue + + if isinstance(values, list): + # If value is a list use `IN` query + self.filters.append([field, 'IN', values]) + else: + # `=` will be faster than `IN` for most cases + self.filters.append([field, '=', values]) + + def build_search_filters(self, search_term): + """Query search term in specified fields + + Args: + search_term (str): Search candidate + """ + # Default fields to search from + default_fields = {'name', 'item_name', 'description', 'item_group'} + + # Get meta search fields + meta = frappe.get_meta("Item") + meta_fields = set(meta.get_search_fields()) + + # Join the meta fields and default fields set + search_fields = default_fields.union(meta_fields) + try: + if frappe.db.count('Item', cache=True) > 50000: + search_fields.remove('description') + except KeyError: + pass + + # Build or filters for query + search = '%{}%'.format(search_term) + self.or_filters += [[field, 'like', search] for field in search_fields] diff --git a/erpnext/shopping_cart/search.py b/erpnext/shopping_cart/search.py new file mode 100644 index 0000000000..012d09fafc --- /dev/null +++ b/erpnext/shopping_cart/search.py @@ -0,0 +1,127 @@ +import frappe +from frappe.search.full_text_search import FullTextSearch +from whoosh.fields import TEXT, ID, KEYWORD, Schema +from frappe.website.render import render_page +from frappe.utils import strip_html_tags +from whoosh.qparser import MultifieldParser, FieldsPlugin, WildcardPlugin +from whoosh.analysis import StemmingAnalyzer +from whoosh.query import Prefix + +INDEX_NAME = "products" + +class ProductSearch(FullTextSearch): + """ Wrapper for WebsiteSearch """ + + def get_schema(self): + return Schema( + title=TEXT(stored=True, field_boost=1.5), + name=ID(stored=True), + path=ID(stored=True), + content=TEXT(stored=True, analyzer=StemmingAnalyzer()), + keywords=KEYWORD(stored=True, scorable=True, commas=True), + ) + + def get_id(self): + return "name" + + def get_items_to_index(self): + """Get all routes to be indexed, this includes the static pages + in www/ and routes from published documents + + Returns: + self (object): FullTextSearch Instance + """ + items = get_all_published_items() + documents = [self.get_document_to_index(item) for item in items] + return documents + + def get_document_to_index(self, item): + try: + item = frappe.get_doc("Item", item) + title = item.item_name + keywords = [item.item_group] + + if item.brand: + keywords.append(item.brand) + + if item.website_image_alt: + keywords.append(item.website_image_alt) + + if item.has_variants and item.variant_based_on == "Item Attribute": + keywords = keywords + [attr.attribute for attr in item.attributes] + + if item.web_long_description: + content = strip_html_tags(item.web_long_description) + elif description: + content = strip_html_tags(item.description) + + return frappe._dict( + title=title, + name=item.name, + path=item.route, + content=content, + keywords=", ".join(keywords), + ) + except Exception: + pass + + def search(self, text, scope=None, limit=20): + """Search from the current index + + Args: + text (str): String to search for + scope (str, optional): Scope to limit the search. Defaults to None. + limit (int, optional): Limit number of search results. Defaults to 20. + + Returns: + [List(_dict)]: Search results + """ + ix = self.get_index() + + results = None + out = [] + + with ix.searcher() as searcher: + parser = MultifieldParser(["title", "content", "keywords"], ix.schema) + parser.remove_plugin_class(FieldsPlugin) + parser.remove_plugin_class(WildcardPlugin) + query = parser.parse(text) + + filter_scoped = None + if scope: + filter_scoped = Prefix(self.id, scope) + results = searcher.search(query, limit=limit, filter=filter_scoped) + + for r in results: + out.append(self.parse_result(r)) + + return out + + def parse_result(self, result): + title_highlights = result.highlights("title") + content_highlights = result.highlights("content") + keyword_highlights = result.highlights("keywords") + + return frappe._dict( + title=result["title"], + path=result["path"], + keywords=result["keywords"], + title_highlights=title_highlights, + content_highlights=content_highlights, + keyword_highlights=keyword_highlights, + ) + +def get_all_published_items(): + return frappe.get_all("Item", filters={"variant_of": "", "show_in_website": 1},pluck="name") + +def update_index_for_path(path): + search = ProductSearch(INDEX_NAME) + return search.update_index_by_name(path) + +def remove_document_from_index(path): + search = ProductSearch(INDEX_NAME) + return search.remove_document_from_index(path) + +def build_index_for_all_routes(): + search = ProductSearch(INDEX_NAME) + return search.build() \ No newline at end of file diff --git a/erpnext/shopping_cart/web_template/__init__.py b/erpnext/shopping_cart/web_template/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/shopping_cart/web_template/hero_slider/__init__.py b/erpnext/shopping_cart/web_template/hero_slider/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.html b/erpnext/shopping_cart/web_template/hero_slider/hero_slider.html new file mode 100644 index 0000000000..1b3953435e --- /dev/null +++ b/erpnext/shopping_cart/web_template/hero_slider/hero_slider.html @@ -0,0 +1,85 @@ +{%- macro slide(image, title, subtitle, action, label, index, align="Left", theme="Dark") -%} +{%- set align_class = resolve_class({ + 'text-right': align == 'Right', + 'text-centre': align == 'Center', + 'text-left': align == 'Left', +}) -%} + +{%- set heading_class = resolve_class({ + 'text-white': theme == 'Dark', + '': theme == 'Light', +}) -%} +
{{ subtitle }}
{%- endif -%} + {%- if action -%} + + {{ label }} + + {%- endif -%} +{{ subtitle }}
+ {%- endif -%} +{{ subtitle }}
+ {%- endif -%} + +