diff --git a/webshop/api/__init__.py b/webshop/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/webshop/api/product_search.py b/webshop/api/product_search.py new file mode 100644 index 0000000000..14596bff98 --- /dev/null +++ b/webshop/api/product_search.py @@ -0,0 +1,25 @@ +# apps/webshop/webshop/api/product_search.py + +import frappe +from frappe import _ + +# @frappe.whitelist() +@frappe.whitelist(allow_guest = True) +def product_search(q): + if not q: + return [] + + or_filters = [ + ["item_code", "like", f"%{q}%"], + ["item_name", "like", f"%{q}%"], + ["web_long_description", "like", f"%{q}%"], + ["web_item_name", "like", f"%{q}%"] + ] + + return frappe.get_all( + "Website Item", + fields=["name", "item_code", "item_name", "item_group", "website_image", "web_item_name", "route"], + or_filters=or_filters, + filters={"published": 1}, + limit_page_length=20 + ) \ No newline at end of file diff --git a/webshop/api/search Temp.py b/webshop/api/search Temp.py new file mode 100644 index 0000000000..3e52fe5a7a --- /dev/null +++ b/webshop/api/search Temp.py @@ -0,0 +1,23 @@ +import frappe +import json +from webshop.api.search import product_search +from webshop.api import get_webshop_settings + + +def get_context(context): + # query = frappe.form_dict.get("query") or "" + query = frappe.form_dict.get("query") or frappe.form_dict.get("q") or "" + + context.query = query + context.products = [] + context.webshop_settings = get_webshop_settings() + + if query: + context.products = product_search(query) + + # تمرير المنتجات كـ JSON إلى JavaScript مع حماية + context.products_json = json.dumps(context.products or [], default=str) + context.webshop_settings_json = json.dumps(context.webshop_settings or {}, default=str) + + return context + diff --git a/webshop/api/search copy.py b/webshop/api/search copy.py new file mode 100644 index 0000000000..4195469377 --- /dev/null +++ b/webshop/api/search copy.py @@ -0,0 +1,52 @@ +# apps/webshop/webshop/api/search.py + +import frappe +from frappe import _ +import json +from frappe.utils.data import flt + +@frappe.whitelist() +def product_search(q): + if not q: + return [] + + or_filters = [ + ["item_code", "like", f"%{q}%"], + ["item_name", "like", f"%{q}%"], + ["web_long_description", "like", f"%{q}%"], + ["web_item_name", "like", f"%{q}%"] + ] + + items = frappe.get_all( + "Website Item", + fields=["name", "item_code", "item_name", "item_group", "website_image", "web_item_name", "route"], + or_filters=or_filters, + filters={"published": 1}, + limit_page_length=20 + ) + + for item in items: + item_doc = frappe.get_doc("Item", item.item_code) + + # جلب المخزون من الـ Website Item نفسه أو إعدادات النظام + warehouse = frappe.db.get_value("Website Item", item.name, "website_warehouse") or \ + frappe.db.get_single_value("Stock Settings", "default_warehouse") + + # جلب الكمية المتوفرة + actual_qty = 0 + if item.item_code and warehouse: + actual_qty = frappe.db.get_value("Bin", { + "item_code": item.item_code, + "warehouse": warehouse + }, "actual_qty") or 0 + + # تحديث خصائص المنتج بالمخزون + item.update({ + "stock_qty": flt(actual_qty), + "in_stock": actual_qty > 0, + "is_stock": item_doc.is_stock_item, + "on_backorder": item_doc.delivered_by_supplier, + "has_variants": item_doc.has_variants + }) + + return items diff --git a/webshop/api/search.py b/webshop/api/search.py new file mode 100644 index 0000000000..c08ec7ddb5 --- /dev/null +++ b/webshop/api/search.py @@ -0,0 +1,60 @@ +# apps/webshop/webshop/api/search.py + +import frappe +from frappe import _ +import json +from frappe.utils.data import flt + +@frappe.whitelist(allow_guest = True) +def product_search(q): + if not q: + return [] + + or_filters = [ + ["item_code", "like", f"%{q}%"], + ["item_name", "like", f"%{q}%"], + ["web_long_description", "like", f"%{q}%"], + ["web_item_name", "like", f"%{q}%"] + ] + + raw_items = frappe.get_all( + "Website Item", + fields=["name", "item_code", "item_name", "item_group", "website_image", "web_item_name", "route"], + or_filters=or_filters, + filters={"published": 1}, + limit_page_length=20 + ) + + hide_unavailable = frappe.db.get_single_value("Webshop Settings", "hide_unavailable_items") or 0 + + visible_items = [] + + for item in raw_items: + item_doc = frappe.get_doc("Item", item.item_code) + + warehouse = frappe.db.get_value("Website Item", item.name, "website_warehouse") or \ + frappe.db.get_single_value("Stock Settings", "default_warehouse") + + actual_qty = 0 + if item.item_code and warehouse: + actual_qty = frappe.db.get_value("Bin", { + "item_code": item.item_code, + "warehouse": warehouse + }, "actual_qty") or 0 + + updated_item = item.copy() + updated_item.update({ + "stock_qty": flt(actual_qty), + "in_stock": actual_qty > 0, + "is_stock": item_doc.is_stock_item, + "on_backorder": item_doc.delivered_by_supplier, + "has_variants": item_doc.has_variants + }) + + if hide_unavailable: + if updated_item["in_stock"] or updated_item["on_backorder"]: + visible_items.append(updated_item) + else: + visible_items.append(updated_item) + + return visible_items \ No newline at end of file diff --git a/webshop/hooks.py b/webshop/hooks.py index ed854d07cd..0c9d2cf8d5 100644 --- a/webshop/hooks.py +++ b/webshop/hooks.py @@ -10,10 +10,23 @@ required_apps = ["payments", "erpnext"] -web_include_css = "webshop-web.bundle.css" +# web_include_css = "webshop-web.bundle.css" + +web_include_css = [ + "webshop-web.bundle.css", + "/assets/webshop/css/custom_style.css" +] web_include_js = "web.bundle.js" +# app_include_css = [ +# "/assets/webshop/css/custom_style.css" +# ] + +app_include_js = [ + "/assets/webshop/js/search.js" +] + after_install = "webshop.setup.install.after_install" on_logout = "webshop.webshop.shopping_cart.utils.clear_cart_count" on_session_creation = [ @@ -23,6 +36,7 @@ update_website_context = [ "webshop.webshop.shopping_cart.utils.update_website_context", ] +my_account_context = "webshop.webshop.shopping_cart.utils.update_my_account_context" website_generators = ["Website Item", "Item Group"] @@ -75,4 +89,4 @@ has_website_permission = { "Website Item": "webshop.webshop.doctype.website_item.website_item.has_website_permission_for_website_item", "Item Group": "webshop.webshop.doctype.website_item.website_item.has_website_permission_for_item_group" -} +} \ No newline at end of file diff --git a/webshop/public/css/custom_style.css b/webshop/public/css/custom_style.css new file mode 100644 index 0000000000..51103e99c6 --- /dev/null +++ b/webshop/public/css/custom_style.css @@ -0,0 +1,124 @@ +.page-header-wrapper { + display: flex; + justify-content: center; + align-items: center; +} + +.filters-section { + background-color: white; + padding: 20px; +} + +/* ========== Mobile Bottom Nav ========== */ + +@media (min-width: 992px) { + .mobile-bottom-nav { + display: none !important; + } +} +.mobile-bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 9999; + display: flex; + justify-content: space-around; + align-items: center; + background: #ffffff; + border-top: 1px solid #ddd; + box-shadow: 0 -1px 5px rgba(0, 0, 0, 0.05); + padding: 6px 0; + font-size: 12px; +} + +.mobile-bottom-nav .nav-item { + text-align: center; + flex: 1; + color: #444; + text-decoration: none; + position: relative; +} + +.mobile-bottom-nav .nav-item svg.icon, +.mobile-bottom-nav .nav-item .icon-sm, +.mobile-bottom-nav .nav-item .icon-svg { + width: 20px; + height: 20px; + fill: #333; + margin-bottom: 2px; + display: block; +} + +/* ========== Badges ========== */ +.cart-badge, +.shopping-badge { + position: absolute; + top: 0; + background-color: #e60023; + color: white; + font-size: 10px; + font-weight: bold; + padding: 2px 6px; + border-radius: 999px; + line-height: 1; + min-width: 18px; + text-align: center; + display: none; + width: fit-content; + left: 50%; + transform: translate(-30px, 0); +} + +/* ========== Animation on Add ========== */ +.bounce { + animation: bounce 0.4s ease; +} + +@keyframes bounce { + 0% { transform: scale(1); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } +} +.navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; +} + + +.product-paging-area { + margin-bottom: 60px; +} + +.breadcrumb-container { + margin-top: 2.5rem; +} + +#filters-btn { + display: block; + position: fixed; + bottom: 80px; + left: 11px; + z-index: 100; + background-color: #0289f7; + border-radius: 50%; + padding: 7px; +} + +#filters-btn svg { + stroke: #ffffff; + height: 29px; + width: 20px; +} + +.btn-add-to-cart-list { + text-wrap: auto; + height: auto; + max-height: unset; + width: fit-content; + min-width: 100px !important; + padding: 0.25rem 0.2rem !important; +} \ No newline at end of file diff --git a/webshop/public/js/init.js b/webshop/public/js/init.js index fa3ebcaccf..eb74c641ae 100644 --- a/webshop/public/js/init.js +++ b/webshop/public/js/init.js @@ -1,2 +1,90 @@ -if (!window.webshop) window.webshop = {} -if (!frappe.boot) frappe.boot = {} +if (!window.webshop) window.webshop = {}; +if (!frappe.boot) frappe.boot = {}; + +frappe.ready(() => { + // if (window.innerWidth < 768 && !document.querySelector(".mobile-bottom-nav")) { + if ( !document.querySelector(".mobile-bottom-nav")) { + const footerHTML = ` +
+ + + + + Category + + + + + + Products + + + + + + + + + Account + +
+ `; + document.body.insertAdjacentHTML("beforeend", footerHTML); + } + + document.body.addEventListener("click", (e) => { + const btn = e.target.closest(".btn-add-to-cart-list"); + if (btn) { + setTimeout(syncNavCountsToFooter, 200); + } + }); + + document.body.addEventListener("click", (e) => { + const icon = e.target.closest(".like-action"); + if (icon && icon.dataset.itemCode) { + e.preventDefault(); + e.stopPropagation(); + icon.click(); + setTimeout(syncNavCountsToFooter, 200); + } + }); + + syncNavCountsToFooter(); + setInterval(syncNavCountsToFooter, 2000); +}); + +function syncNavCountsToFooter() { + const cartTop = document.querySelector("#cart-count"); + const cartMobile = document.querySelector("#cart-count-mobile"); + const cartLi = document.querySelector(".mobile-bottom-nav .cart-icon"); + if (cartTop && cartMobile) { + const val = parseInt(cartTop.innerText || "0"); + cartMobile.innerText = val; + cartLi?.classList.toggle("hidden", val === 0); + cartMobile.style.display = val > 0 ? "inline-block" : "none"; + } + + const wishTop = document.querySelector("#wish-count"); + const wishMobile = document.querySelector("#wish-count-mobile"); + const wishLi = document.querySelector(".mobile-bottom-nav .wishlist-icon"); + if (wishTop && wishMobile) { + const val = parseInt(wishTop.innerText || "0"); + wishMobile.innerText = val; + wishLi?.classList.toggle("hidden", val === 0); + wishMobile.style.display = val > 0 ? "inline-block" : "none"; + } +} diff --git a/webshop/public/js/product_ui/grid copy.js b/webshop/public/js/product_ui/grid copy.js new file mode 100644 index 0000000000..97569a5ebf --- /dev/null +++ b/webshop/public/js/product_ui/grid copy.js @@ -0,0 +1,226 @@ +window.webshop = window.webshop || {}; + +webshop.ProductGrid = class { + /* Options: + - items: Items + - settings: Webshop Settings + - products_section: Products Wrapper + - preference: If preference is not grid view, render but hide + */ + constructor(options) { + Object.assign(this, options); + + if (this.preference !== "Grid View") { + this.products_section.addClass("hidden"); + } + + this.products_section.empty(); + this.make(); + } + + make() { + let me = this; + let html = ``; + + // this.items.forEach(item => { + // let title = item.web_item_name || item.item_name || item.item_code || ""; + // title = title.length > 90 ? title.substr(0, 90) + "..." : title; + + // html += `
`; + // html += me.get_image_html(item, title); + // html += me.get_card_body_html(item, title, me.settings); + // html += `
`; + // }); + + + this.items.forEach(item => { + if (me.settings.hide_unavailable_items && !item.in_stock) { + return; + } + + let title = item.web_item_name || item.item_name || item.item_code || ""; + title = title.length > 90 ? title.substr(0, 90) + "..." : title; + + html += `
`; + html += me.get_image_html(item, title); + html += me.get_card_body_html(item, title, me.settings); + html += `
`; + }); + + let $product_wrapper = this.products_section; + $product_wrapper.append(html); + } + + get_image_html(item, title) { + let image = item.website_image; + + if (image) { + return ` +
+ + ${ title } + +
+ `; + } else { + return ` +
+ +
+ ${ frappe.get_abbr(title) } +
+
+
+ `; + } + } + + get_card_body_html(item, title, settings) { + let body_html = ` +
+
+ `; + body_html += this.get_title(item, title); + + // get floating elements + if (!item.has_variants) { + if (settings.enable_wishlist) { + body_html += this.get_wishlist_icon(item); + } + if (settings.enabled) { + body_html += this.get_cart_indicator(item); + } + + } + + body_html += `
`; + body_html += `
${ item.item_group || '' }
`; + + if (item.formatted_price) { + body_html += this.get_price_html(item); + } + + body_html += this.get_stock_availability(item, settings); + body_html += this.get_primary_button(item, settings); + body_html += `
`; // close div on line 49 + + return body_html; + } + + get_title(item, title) { + let title_html = ` + +
+ ${ title || '' } +
+
+ `; + return title_html; + } + + get_wishlist_icon(item) { + let icon_class = item.wished ? "wished" : "not-wished"; + return ` +
+ + + +
+ `; + } + + get_cart_indicator(item) { + return ` +
+ 1 +
+ `; + } + + get_price_html(item) { + let price_html = ` +
+ ${ item.formatted_price || '' } + `; + + if (item.formatted_mrp) { + price_html += ` + + ${ item.formatted_mrp ? item.formatted_mrp.replace(/ +/g, "") : "" } + + + ${ item.discount } ${ __("OFF") } + + `; + } + price_html += `
`; + return price_html; + } + + get_stock_availability(item, settings) { + if (settings.show_stock_availability && !item.has_variants) { + if (item.on_backorder) { + return ` + + ${ __("Available on backorder") } + + `; + } else if (!item.in_stock) { + return ` + + ${ __("Out of stock") } + + `; + } else { + let qty_display = `${ __("In stock") }`; + if (item.stock_qty) { + qty_display += ` (${item.stock_qty})`; + } + return ` + + ${qty_display} + + `; + } + } + return ``; + } + get_primary_button(item, settings) { + if (item.has_variants) { + return ` + +
+ ${ __("Explore") } +
+
+ `; + } else if (settings.enabled && (settings.allow_items_not_in_stock || item.in_stock)) { + return ` +
+ + + + + + ${ settings.enable_checkout ? __("Add to Cart") : __("Add to Quote") } +
+ + +
+ ${ settings.enable_checkout ? __("Go to Cart") : __("Go to Quote") } +
+
+ `; + } else { + return ``; + } + } +}; diff --git a/webshop/public/js/product_ui/grid.js b/webshop/public/js/product_ui/grid.js index 0212d823a2..f825155704 100644 --- a/webshop/public/js/product_ui/grid.js +++ b/webshop/public/js/product_ui/grid.js @@ -1,3 +1,5 @@ +window.webshop = window.webshop || {}; + webshop.ProductGrid = class { /* Options: - items: Items @@ -20,15 +22,30 @@ webshop.ProductGrid = class { let me = this; let html = ``; - this.items.forEach(item => { - let title = item.web_item_name || item.item_name || item.item_code || ""; - title = title.length > 90 ? title.substr(0, 90) + "..." : title; + // this.items.forEach(item => { + // let title = item.web_item_name || item.item_name || item.item_code || ""; + // title = title.length > 90 ? title.substr(0, 90) + "..." : title; + + // html += `
`; + // html += me.get_image_html(item, title); + // html += me.get_card_body_html(item, title, me.settings); + // html += `
`; + // }); + + + this.items.forEach(item => { + if (me.settings.hide_unavailable_items && !item.in_stock) { + return; + } + + let title = item.web_item_name || item.item_name || item.item_code || ""; + title = title.length > 90 ? title.substr(0, 90) + "..." : title; - html += `
`; - html += me.get_image_html(item, title); - html += me.get_card_body_html(item, title, me.settings); - html += `
`; - }); + html += `
`; + html += me.get_image_html(item, title); + html += me.get_card_body_html(item, title, me.settings); + html += `
`; + }); let $product_wrapper = this.products_section; $product_wrapper.append(html); @@ -155,12 +172,22 @@ webshop.ProductGrid = class { ${ __("Out of stock") } `; + } else { + let qty_display = `${ __("In stock") }`; + if (item.stock_qty) { + qty_display += ` (${item.stock_qty})`; + } + return ` + + ${qty_display} + + `; } } - return ``; } + get_primary_button(item, settings) { if (item.has_variants) { return ` diff --git a/webshop/public/js/product_ui/list.js b/webshop/public/js/product_ui/list.js index b4df29d8f2..8a3abf8c73 100644 --- a/webshop/public/js/product_ui/list.js +++ b/webshop/public/js/product_ui/list.js @@ -1,3 +1,5 @@ +window.webshop = window.webshop || {}; + webshop.ProductList = class { /* Options: - items: Items @@ -21,6 +23,10 @@ webshop.ProductList = class { let html = `

`; this.items.forEach(item => { + if (me.settings.hide_unavailable_items && !item.in_stock) { + return; + } + let title = item.web_item_name || item.item_name || item.item_code || ""; title = title.length > 200 ? title.substr(0, 200) + "..." : title; @@ -138,17 +144,23 @@ webshop.ProductList = class {
${ __("Out of stock") } `; - } else if (item.is_stock) { + } else { + let qty_display = `${ __("In stock") }`; + if (item.stock_qty) { + qty_display += ` (${item.stock_qty})`; + } return `
- ${ __("In stock") } + + ${qty_display} + `; } } return ``; } + get_wishlist_icon(item) { let icon_class = item.wished ? "wished" : "not-wished"; diff --git a/webshop/public/js/product_ui/views.js b/webshop/public/js/product_ui/views.js index 3863ff8c74..550c1ba8ec 100644 --- a/webshop/public/js/product_ui/views.js +++ b/webshop/public/js/product_ui/views.js @@ -4,12 +4,32 @@ webshop.ProductView = class { - Products Section Wrapper, - Item Group: If its an Item Group page */ + // constructor(options) { + // Object.assign(this, options); + // this.preference = this.view_type; + // this.make(); + // this.search_text = ""; + // } +// Updated By Osama constructor(options) { Object.assign(this, options); - this.preference = this.view_type; + + const isMobileOrTablet = window.innerWidth < 768; + + const savedPreference = localStorage.getItem("product_view"); + + if (!savedPreference && isMobileOrTablet) { + this.preference = "Grid View"; // أو "List View" حسب الزر + localStorage.setItem("product_view", this.preference); + } else { + this.preference = savedPreference || this.view_type; + } + this.make(); + this.search_text = ""; } + make(from_filters=false) { this.products_section.empty(); this.prepare_toolbar(); @@ -24,7 +44,7 @@ webshop.ProductView = class { this.prepare_search(); this.prepare_view_toggler(); - new webshop.ProductSearch(); + // new webshop.ProductSearch(); } prepare_view_toggler() { @@ -130,17 +150,41 @@ webshop.ProductView = class { `); } + // get_query_filters() { + // const filters = frappe.utils.get_query_params(); + // let {field_filters, attribute_filters} = filters; + + // field_filters = field_filters ? JSON.parse(field_filters) : {}; + // attribute_filters = attribute_filters ? JSON.parse(attribute_filters) : {}; + + // return { + // field_filters: field_filters, + // attribute_filters: attribute_filters, + // item_group: this.item_group, + // search: this.search_text || "", + // start: filters.start || null, + // from_filters: this.from_filters || false + + // }; + // } + get_query_filters() { const filters = frappe.utils.get_query_params(); - let {field_filters, attribute_filters} = filters; + let { field_filters, attribute_filters } = filters; - field_filters = field_filters ? JSON.parse(field_filters) : {}; - attribute_filters = attribute_filters ? JSON.parse(attribute_filters) : {}; + if (this.search_text && this.search_text.trim()) { + field_filters = {}; + attribute_filters = {}; + } else { + field_filters = field_filters ? JSON.parse(field_filters) : {}; + attribute_filters = attribute_filters ? JSON.parse(attribute_filters) : {}; + } return { field_filters: field_filters, attribute_filters: attribute_filters, - item_group: this.item_group, + item_group: this.search_text ? null : this.item_group, // تجاهل group لو كان بحث + search: this.search_text || "", start: filters.start || null, from_filters: this.from_filters || false }; @@ -152,9 +196,8 @@ webshop.ProductView = class { if (this.products) { let paging_html = `
-
-
-
+
+
`; let query_params = frappe.utils.get_query_params(); let start = query_params.start ? cint(JSON.parse(query_params.start)) : 0; @@ -176,35 +219,81 @@ webshop.ProductView = class { `; - paging_html += `
`; + paging_html += `
+
`; $(".page_content").append(paging_html); this.bind_paging_action(); } } + // prepare_search() { + // $(".toolbar").append(` + //
+ // + //
+ // `); + // $("#search-box").on("keypress", function (e) { + // if (e.which === 13) { + // e.preventDefault(); + // const query = $(this).val().trim(); + // if (query) { + // window.location.href = `/search?q=${encodeURIComponent(query)}`; + // } + // } + // }); + // } +// Updated By Osama prepare_search() { - $(".toolbar").append(` -
- -
\ No newline at end of file + diff --git a/webshop/templates/includes/macros.html b/webshop/templates/includes/macros.html index 913aa776a8..bda4176094 100644 --- a/webshop/templates/includes/macros.html +++ b/webshop/templates/includes/macros.html @@ -302,7 +302,7 @@

{% if values | len > 20 %} - + {% endif %} {% if values %} diff --git a/webshop/templates/pages/cart.js b/webshop/templates/pages/cart.js index dd03f3d1c7..f7c328f92a 100644 --- a/webshop/templates/pages/cart.js +++ b/webshop/templates/pages/cart.js @@ -21,18 +21,33 @@ $.extend(shopping_cart, { shopping_cart.bind_remove_coupon_code(); }, - bind_place_order: function() { - $(".btn-place-order").on("click", function() { - shopping_cart.place_order(this); + bind_place_order: function () { + $(".btn-place-order").off("click").on("click", function (e) { + e.preventDefault(); + const btn = this; + frappe.confirm( + "Are you sure you want to place this order?", + function () { + shopping_cart.place_order(btn); + } + ); }); }, - bind_request_quotation: function() { - $('.btn-request-for-quotation').on('click', function() { - shopping_cart.request_quotation(this); + bind_request_quotation: function () { + $(".btn-request-for-quotation").off("click").on("click", function (e) { + e.preventDefault(); + const btn = this; + frappe.confirm( + "Are you sure you want to request a quotation?", + function () { + shopping_cart.request_quotation(btn); + } + ); }); }, + bind_change_qty: function() { // bind update button $(".cart-items").on("change", ".cart-qty", function() { diff --git a/webshop/templates/pages/product_search.py b/webshop/templates/pages/product_search.py index ba8348e566..f176b526be 100644 --- a/webshop/templates/pages/product_search.py +++ b/webshop/templates/pages/product_search.py @@ -63,11 +63,30 @@ def get_product_data(search=None, start=0, limit=12): return frappe.db.sql(query, {"search": search}, as_dict=1) # nosemgrep +# @frappe.whitelist(allow_guest=True) +# def search(query): +# product_results = product_search(query) +# category_results = get_category_suggestions(query) + +# return { +# "product_results": product_results.get("results") or [], +# "category_results": category_results.get("results") or [], +# } + @frappe.whitelist(allow_guest=True) def search(query): product_results = product_search(query) category_results = get_category_suggestions(query) + webshop_settings = frappe.get_single("Webshop Settings") + hide_unavailable = webshop_settings.hide_unavailable_items + + if hide_unavailable: + product_results["results"] = [ + item for item in product_results["results"] + if frappe.utils.cint(item.get("actual_qty") or 0) > 0 + ] + return { "product_results": product_results.get("results") or [], "category_results": category_results.get("results") or [], diff --git a/webshop/webshop/api.py b/webshop/webshop/api.py index 82650d868d..37cf90d4d2 100644 --- a/webshop/webshop/api.py +++ b/webshop/webshop/api.py @@ -5,55 +5,232 @@ import json import frappe -from frappe.utils import cint +from frappe.utils import cint, flt from webshop.webshop.product_data_engine.filters import ProductFiltersBuilder from webshop.webshop.product_data_engine.query import ProductQuery from webshop.webshop.doctype.override_doctype.item_group import get_child_groups_for_website +# @frappe.whitelist(allow_guest=True) +# def get_product_filter_data(query_args=None): +# """ +# Returns filtered products and discount filters. + +# Args: +# query_args (dict): contains filters to get products list + +# Query Args filters: +# search (str): Search Term. +# field_filters (dict): Keys include item_group, brand, etc. +# attribute_filters(dict): Keys include Color, Size, etc. +# start (int): Offset items by +# item_group (str): Valid Item Group +# from_filters (bool): Set as True to jump to page 1 +# """ +# if isinstance(query_args, str): +# query_args = json.loads(query_args) + +# query_args = frappe._dict(query_args or {}) + +# if query_args: +# search = query_args.get("search") +# field_filters = query_args.get("field_filters", {}) +# attribute_filters = query_args.get("attribute_filters", {}) +# start = cint(query_args.start) if query_args.get("start") else 0 +# item_group = query_args.get("item_group") +# from_filters = query_args.get("from_filters") +# else: +# search, attribute_filters, item_group, from_filters = None, None, None, None +# field_filters = {} +# start = 0 + +# # if new filter is checked, reset start to show filtered items from page 1 +# if from_filters: +# start = 0 + +# sub_categories = [] +# if item_group: +# sub_categories = get_child_groups_for_website(item_group, immediate=True) + +# engine = ProductQuery() + +# try: +# result = engine.query( +# attribute_filters, +# field_filters, +# search_term=search, +# start=start, +# item_group=item_group, +# ) +# except Exception: +# frappe.log_error("Product query with filter failed") +# return {"exc": "Something went wrong!"} + +# # discount filter data +# filters = {} +# discounts = result["discounts"] + +# if discounts: +# filter_engine = ProductFiltersBuilder() +# filters["discount_filters"] = filter_engine.get_discount_filters(discounts) + +# return { +# "items": result["items"] or [], +# "filters": filters, +# "settings": engine.settings, +# "sub_categories": sub_categories, +# "items_count": result["items_count"], +# } + +# ############################################################################################### + +# @frappe.whitelist(allow_guest=True) +# def get_product_filter_data(query_args=None): +# """ +# Returns filtered products and discount filters. + +# Args: +# query_args (dict): contains filters to get products list + +# Query Args filters: +# search (str): Search Term. +# field_filters (dict): Keys include item_group, brand, etc. +# attribute_filters(dict): Keys include Color, Size, etc. +# start (int): Offset items by +# item_group (str): Valid Item Group +# from_filters (bool): Set as True to jump to page 1 +# """ +# if isinstance(query_args, str): +# query_args = json.loads(query_args) + +# query_args = frappe._dict(query_args or {}) + +# if query_args: +# search = query_args.get("search") +# field_filters = query_args.get("field_filters", {}) +# attribute_filters = query_args.get("attribute_filters", {}) +# start = cint(query_args.start) if query_args.get("start") else 0 +# item_group = query_args.get("item_group") +# from_filters = query_args.get("from_filters") +# else: +# search, attribute_filters, item_group, from_filters = None, None, None, None +# field_filters = {} +# start = 0 + +# # if new filter is checked, reset start to show filtered items from page 1 +# if from_filters: +# start = 0 + +# sub_categories = [] +# if item_group: +# sub_categories = get_child_groups_for_website(item_group, immediate=True) + +# engine = ProductQuery() + +# try: +# result = engine.query( +# attribute_filters, +# field_filters, +# search_term=search, +# start=start, +# item_group=item_group, +# ) +# except Exception: +# frappe.log_error("Product query with filter failed") +# return {"exc": "Something went wrong!"} + +# # discount filter data +# filters = {} +# discounts = result["discounts"] + +# if discounts: +# filter_engine = ProductFiltersBuilder() +# filters["discount_filters"] = filter_engine.get_discount_filters(discounts) + +# for item in result["items"]: +# item_code = item.get("item_code") +# website_warehouse = item.get("website_warehouse") + +# if not website_warehouse: +# website_warehouse = frappe.db.get_value("Website Item", item.get("name"), "website_warehouse") + +# actual_qty = 0 +# if item_code and website_warehouse: +# actual_qty = frappe.db.get_value("Bin", { +# "item_code": item_code, +# "warehouse": website_warehouse +# }, "actual_qty") or 0 + +# item["actual_qty"] = flt(actual_qty) +# item["stock_qty"] = flt(actual_qty) +# item["in_stock"] = actual_qty > 0 + + +# return { +# "items": result["items"] or [], +# "filters": filters, +# "settings": engine.settings, +# "sub_categories": sub_categories, +# "items_count": result["items_count"], +# } + +# ############################################################################################### + +# @frappe.whitelist() +# def get_product_filter_data(start=0, search=None, item_group=None, fields=None, attributes=None): +# if isinstance(fields, str): +# fields = json.loads(fields) +# if isinstance(attributes, str): +# attributes = json.loads(attributes) + +# from webshop.webshop.product_data_engine.query import ProductQuery + +# # استخدم Webshop Settings لتحديد الترتيب +# settings = frappe.get_cached_doc("Webshop Settings") +# # sort_by = settings.sort_by or "ranking_desc" +# sort_by = settings.sort_by or "most_used" + + +# query_engine = ProductQuery() +# query_engine.set_sort_order(sort_by) + +# frappe.log_error("Using sort order: " + query_engine.sort_order) + +# return query_engine.query( +# attributes=attributes, +# fields=fields, +# search_term=search, +# start=start, +# item_group=item_group, +# ) + +# ############################################################################################### + @frappe.whitelist(allow_guest=True) def get_product_filter_data(query_args=None): - """ - Returns filtered products and discount filters. - - Args: - query_args (dict): contains filters to get products list - - Query Args filters: - search (str): Search Term. - field_filters (dict): Keys include item_group, brand, etc. - attribute_filters(dict): Keys include Color, Size, etc. - start (int): Offset items by - item_group (str): Valid Item Group - from_filters (bool): Set as True to jump to page 1 - """ if isinstance(query_args, str): query_args = json.loads(query_args) query_args = frappe._dict(query_args or {}) - if query_args: - search = query_args.get("search") - field_filters = query_args.get("field_filters", {}) - attribute_filters = query_args.get("attribute_filters", {}) - start = cint(query_args.start) if query_args.get("start") else 0 - item_group = query_args.get("item_group") - from_filters = query_args.get("from_filters") - else: - search, attribute_filters, item_group, from_filters = None, None, None, None - field_filters = {} - start = 0 + search = query_args.get("search") + field_filters = query_args.get("field_filters", {}) + attribute_filters = query_args.get("attribute_filters", {}) + start = cint(query_args.get("start") or 0) + item_group = query_args.get("item_group") + from_filters = query_args.get("from_filters") - # if new filter is checked, reset start to show filtered items from page 1 if from_filters: start = 0 - sub_categories = [] - if item_group: - sub_categories = get_child_groups_for_website(item_group, immediate=True) + settings = frappe.get_cached_doc("Webshop Settings") + sort_by = settings.sort_by or "most_used" engine = ProductQuery() + engine.set_sort_order(sort_by) + + frappe.log_error("Using sort order: " + engine.sort_order) try: result = engine.query( @@ -63,11 +240,10 @@ def get_product_filter_data(query_args=None): start=start, item_group=item_group, ) - except Exception: - frappe.log_error("Product query with filter failed") + except Exception as e: + frappe.log_error(f"Product query failed: {frappe.get_traceback()}") return {"exc": "Something went wrong!"} - # discount filter data filters = {} discounts = result["discounts"] @@ -75,6 +251,22 @@ def get_product_filter_data(query_args=None): filter_engine = ProductFiltersBuilder() filters["discount_filters"] = filter_engine.get_discount_filters(discounts) + for item in result["items"]: + item_code = item.get("item_code") + website_warehouse = item.get("website_warehouse") or frappe.db.get_value( + "Website Item", item.get("name"), "website_warehouse" + ) + + actual_qty = get_total_stock_qty(item_code, website_warehouse) + + item["actual_qty"] = flt(actual_qty) + item["stock_qty"] = flt(actual_qty) + item["in_stock"] = actual_qty > 0 + + sub_categories = [] + if item_group: + sub_categories = get_child_groups_for_website(item_group, immediate=True) + return { "items": result["items"] or [], "filters": filters, @@ -84,6 +276,28 @@ def get_product_filter_data(query_args=None): } +def get_total_stock_qty(item_code, warehouse=None): + """حساب الكمية الكلية بناءً على شروط المستودع""" + from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses + + # تحديد المستودعات + if warehouse: + if frappe.get_cached_value("Warehouse", warehouse, "is_group"): + warehouses = get_child_warehouses(warehouse) + else: + warehouses = [warehouse] + else: + warehouses = frappe.db.get_all("Warehouse", filters={"is_group": 0}, pluck="name") + + total_qty = 0.0 + + for wh in warehouses: + qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": wh}, "actual_qty") + total_qty += flt(qty) or 0.0 + + return total_qty + + @frappe.whitelist(allow_guest=True) def get_guest_redirect_on_action(): return frappe.db.get_single_value("Webshop Settings", "redirect_on_action") diff --git a/webshop/webshop/doctype/webshop_settings/webshop_settings.json b/webshop/webshop/doctype/webshop_settings/webshop_settings.json index 024ec2efd3..737a6e3d99 100644 --- a/webshop/webshop/doctype/webshop_settings/webshop_settings.json +++ b/webshop/webshop/doctype/webshop_settings/webshop_settings.json @@ -15,10 +15,12 @@ "hide_variants", "enable_variants", "show_price", + "sort_by", "column_break_9", "login_required_to_view_products", "show_stock_availability", "show_quantity_in_website", + "hide_unavailable_items", "allow_items_not_in_stock", "column_break_13", "show_apply_coupon_code_in_website", @@ -382,12 +384,26 @@ "fieldtype": "Check", "in_list_view": 1, "label": "Allow Non Website Items in Cart Quotation" + }, + { + "default": "0", + "depends_on": "show_stock_availability", + "fieldname": "hide_unavailable_items", + "fieldtype": "Check", + "label": " Hide Unavailable Items" + }, + { + "fieldname": "sort_by", + "fieldtype": "Select", + "label": "Sort By", + "options": "\nLast Updated On Asc\nLast Updated On Desc\nWebsite Item Name Asc\nWebsite Item Name Desc\nID Asc\nID Desc\nCreated On Asc\nCreated On Desc\nMost Used\nRoute\nItem Code Asc\nItem Code Desc\nItem Group Asc\nItem Group Desc" } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-03-22 13:01:51.906319", + "modified": "2025-05-19 13:38:51.119954", "modified_by": "Administrator", "module": "Webshop", "name": "Webshop Settings", @@ -408,6 +424,7 @@ "role": "All" } ], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [], diff --git a/webshop/webshop/doctype/website_item/website_item.json b/webshop/webshop/doctype/website_item/website_item.json index e5adbd778c..4c55e05177 100644 --- a/webshop/webshop/doctype/website_item/website_item.json +++ b/webshop/webshop/doctype/website_item/website_item.json @@ -184,7 +184,9 @@ "fetch_from": "item_code.item_group", "fieldname": "item_group", "fieldtype": "Link", + "in_filter": 1, "in_list_view": 1, + "in_standard_filter": 1, "label": "Item Group", "options": "Item Group", "read_only": 1, @@ -202,7 +204,6 @@ "fetch_from": "item_code.has_variants", "fieldname": "has_variants", "fieldtype": "Check", - "in_standard_filter": 1, "label": "Has Variants", "no_copy": 1, "read_only": 1 @@ -347,7 +348,7 @@ "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2024-11-05 13:41:45.347700", + "modified": "2025-05-19 14:39:31.456099", "modified_by": "Administrator", "module": "Webshop", "name": "Website Item", diff --git a/webshop/webshop/product_data_engine/query.py b/webshop/webshop/product_data_engine/query.py index 4fb6c60543..5a422f5fec 100644 --- a/webshop/webshop/product_data_engine/query.py +++ b/webshop/webshop/product_data_engine/query.py @@ -23,6 +23,7 @@ class ProductQuery: def __init__(self): self.settings = frappe.get_doc("Webshop Settings") self.page_length = self.settings.products_per_page or 20 + self.sort_order = "ranking desc" # New self.or_filters = [] self.filters = [["published", "=", 1]] @@ -43,6 +44,27 @@ def __init__(self): "on_backorder", ] + def set_sort_order(self, sort_by): + sort_map = { + "Last Updated On Asc": "modified asc", + "Last Updated On Desc": "modified desc", + "Website Item Name Asc": "item_name asc", + "Website Item Name Desc": "item_name desc", + "ID Asc": "name asc", + "ID Desc": "name desc", + "Created On Asc": "creation asc", + "Created On Desc": "creation desc", + "Most Used": "ranking desc", + "Route": "route", + "Item Code Asc": "item_code asc", + "Item Code Desc": "item_code desc", + "Item Group Asc": "item_group asc", + "Item Group Desc": "item_group desc", + } + + self.sort_order = sort_map.get(sort_by, "ranking desc") + + def query(self, attributes=None, fields=None, search_term=None, start=0, item_group=None): """ Args: @@ -100,7 +122,8 @@ def query_items(self, start=0): or_filters=self.or_filters, limit_page_length=184467440737095516, limit_start=start, # get all items from this offset for total count ahead - order_by="ranking desc", + order_by=self.sort_order, + # order_by="ranking desc", ) count = len(count_items) @@ -117,7 +140,8 @@ def query_items(self, start=0): or_filters=self.or_filters, limit_page_length=page_length, limit_start=start, - order_by="ranking desc", + order_by=self.sort_order, + # order_by="ranking desc", ) return items, count diff --git a/webshop/webshop/redisearch_utils.py b/webshop/webshop/redisearch_utils.py index 85e59624c3..7207026534 100644 --- a/webshop/webshop/redisearch_utils.py +++ b/webshop/webshop/redisearch_utils.py @@ -163,7 +163,8 @@ def delete_item_from_index(website_item_doc): def delete_from_ac_dict(website_item_doc): """Removes this items's name from autocomplete dictionary""" ac = frappe.cache().ft() - ac.sugdel(website_item_doc.web_item_name) + ac.sugdel("autocomplete_key", website_item_doc.web_item_name) + @if_redisearch_enabled diff --git a/webshop/webshop/shopping_cart/cart.py b/webshop/webshop/shopping_cart/cart.py index f4ed155e42..5aeec4d959 100644 --- a/webshop/webshop/shopping_cart/cart.py +++ b/webshop/webshop/shopping_cart/cart.py @@ -747,6 +747,7 @@ def get_shipping_rules(quotation=None, cart_settings=None): return shipping_rules + def get_address_territory(address_name): """Tries to match city, state and country of address to existing territory""" territory = None diff --git a/webshop/webshop/shopping_cart/product_info.py b/webshop/webshop/shopping_cart/product_info.py index 97e5871f01..faddf9a206 100644 --- a/webshop/webshop/shopping_cart/product_info.py +++ b/webshop/webshop/shopping_cart/product_info.py @@ -76,7 +76,8 @@ def get_product_info_for_website(item_code, skip_quotation_creation=False): product_info["in_stock"] = ( stock_status.in_stock if stock_status.is_stock_item - else get_non_stock_item_status(item_code, "website_warehouse") + else + (item_code, "website_warehouse") ) product_info["show_stock_qty"] = show_quantity_in_website() diff --git a/webshop/webshop/utils/product.py b/webshop/webshop/utils/product.py index d4b80b0468..9369a24022 100644 --- a/webshop/webshop/utils/product.py +++ b/webshop/webshop/utils/product.py @@ -3,65 +3,87 @@ from erpnext.stock.doctype.batch.batch import get_batch_qty from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses +from frappe.utils import flt def get_web_item_qty_in_stock(item_code, item_warehouse_field, warehouse=None): - in_stock, stock_qty = 0, "" + in_stock, total_stock = 0, 0.0 + template_item_code, is_stock_item = frappe.db.get_value( "Item", item_code, ["variant_of", "is_stock_item"] ) + # أول محاولة للحصول على المخزن من Website Item if not warehouse: warehouse = frappe.db.get_value("Website Item", {"item_code": item_code}, item_warehouse_field) + # إذا ما زال لا يوجد مخزن، جرّب الصنف القالب if not warehouse and template_item_code and template_item_code != item_code: - warehouse = frappe.db.get_value( - "Website Item", {"item_code": template_item_code}, item_warehouse_field - ) + warehouse = frappe.db.get_value("Website Item", {"item_code": template_item_code}, item_warehouse_field) - if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1: - warehouses = get_child_warehouses(warehouse) + # معالجة حالة المخزن + if warehouse: + if frappe.get_cached_value("Warehouse", warehouse, "is_group"): + warehouses = get_child_warehouses(warehouse) + else: + warehouses = [warehouse] else: - warehouses = [warehouse] if warehouse else [] - - total_stock = 0.0 - if warehouses: - for warehouse in warehouses: - stock_qty = frappe.db.sql( - """ - select S.actual_qty / IFNULL(C.conversion_factor, 1) - from tabBin S - inner join `tabItem` I on S.item_code = I.Item_code - left join `tabUOM Conversion Detail` C on I.sales_uom = C.uom and C.parent = I.Item_code - where S.item_code=%s and S.warehouse=%s""", - (item_code, warehouse), - ) - - if stock_qty: - total_stock += adjust_qty_for_expired_items(item_code, stock_qty, warehouse) - - in_stock = total_stock > 0 and 1 or 0 - - return frappe._dict( - {"in_stock": in_stock, "stock_qty": total_stock, "is_stock_item": is_stock_item} - ) + # إذا لا يوجد مخزن، خذ كل المخازن التي ليست مجموعة + warehouses = frappe.db.get_all("Warehouse", filters={"is_group": 0}, pluck="name") + + # جمع الكميات + for wh in warehouses: + stock_qty = frappe.db.sql( + """ + SELECT S.actual_qty / IFNULL(C.conversion_factor, 1) + FROM tabBin S + INNER JOIN `tabItem` I ON S.item_code = I.item_code + LEFT JOIN `tabUOM Conversion Detail` C + ON I.sales_uom = C.uom AND C.parent = I.item_code + WHERE S.item_code = %s AND S.warehouse = %s + """, + (item_code, wh), + ) + + if stock_qty and stock_qty[0][0] is not None: + qty = flt(stock_qty[0][0]) + total_stock += adjust_qty_for_expired_items(item_code, qty, wh) + + in_stock = 1 if total_stock > 0 else 0 + + return frappe._dict({ + "in_stock": in_stock, + "stock_qty": total_stock, + "is_stock_item": is_stock_item + }) + def adjust_qty_for_expired_items(item_code, stock_qty, warehouse): + # تحويل stock_qty إلى قيمة عددية موحدة + if isinstance(stock_qty, (int, float)): + stock_qty_value = stock_qty + elif isinstance(stock_qty, list): + # مثال: [[10.0]] → 10.0 + stock_qty_value = stock_qty[0][0] if stock_qty and stock_qty[0] else 0 + else: + stock_qty_value = 0 + + # استعلام الدُفعات وانتهاء الصلاحية batches = frappe.get_all("Batch", filters=[{"item": item_code}], fields=["expiry_date", "name"]) expired_batches = get_expired_batches(batches) - stock_qty = [list(item) for item in stock_qty] for batch in expired_batches: if warehouse: - stock_qty[0][0] = max(0, stock_qty[0][0] - get_batch_qty(batch, warehouse)) + stock_qty_value = max(0, stock_qty_value - get_batch_qty(batch, warehouse)) else: - stock_qty[0][0] = max(0, stock_qty[0][0] - qty_from_all_warehouses(get_batch_qty(batch))) + stock_qty_value = max(0, stock_qty_value - qty_from_all_warehouses(get_batch_qty(batch))) - if not stock_qty[0][0]: + if not stock_qty_value: break - return stock_qty[0][0] if stock_qty else 0 + return stock_qty_value + def get_expired_batches(batches): diff --git a/webshop/webshop/variant_selector/utils.py b/webshop/webshop/variant_selector/utils.py index 976a6e2074..818aa75b07 100644 --- a/webshop/webshop/variant_selector/utils.py +++ b/webshop/webshop/variant_selector/utils.py @@ -194,7 +194,9 @@ def get_next_attribute_and_values(item_code, selected_attributes): if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1: warehouses = get_child_warehouses(warehouse) else: - warehouses = [warehouse] if warehouse else [] + all_warehouses = frappe.db.get_all("Warehouse", filters={"is_group": 0}, pluck="name") + + warehouses = [warehouse] if warehouse else all_warehouses for warehouse in warehouses: available_qty += flt( diff --git a/webshop/www/all-products/index copy.html b/webshop/www/all-products/index copy.html new file mode 100644 index 0000000000..cbd76632b6 --- /dev/null +++ b/webshop/www/all-products/index copy.html @@ -0,0 +1,51 @@ +{% from "webshop/templates/includes/macros.html" import attribute_filter_section, field_filter_section, discount_range_filters %} +{% extends "templates/web.html" %} + +{% block title %}{{ _("All Products") }}{% endblock %} +{% block header %} +
{{ _("All Products") }}
+{% endblock header %} + +{% block page_content %} +
+ +
+ +
+ + +
+
+
+
{{ _('Filters') }}
+ {{ _('Clear All') }} +
+ + {% if field_filters %} + {{ _(field_filter_section(field_filters)) }} + {% endif %} + + + {% if attribute_filters %} + {{ _(attribute_filter_section(attribute_filters)) }} + {% endif %} +
+ +
+
+ + + +{% endblock %} diff --git a/webshop/www/all-products/index.html b/webshop/www/all-products/index.html index cbd76632b6..4678e01fe8 100644 --- a/webshop/www/all-products/index.html +++ b/webshop/www/all-products/index.html @@ -8,18 +8,28 @@ {% block page_content %}
+ +
+ +
+
- -
-
+ +
+
-
{{ _('Filters') }}
+
{{ _('Filters') }}
{{ _('Clear All') }}
+ {% if field_filters %} {{ _(field_filter_section(field_filters)) }} @@ -30,10 +40,54 @@ {{ _(attribute_filter_section(attribute_filters)) }} {% endif %}
-
+ + + + + + + + + {% endblock %} diff --git a/webshop/www/search Temp.html b/webshop/www/search Temp.html new file mode 100644 index 0000000000..827de5d2ef --- /dev/null +++ b/webshop/www/search Temp.html @@ -0,0 +1,46 @@ +{% extends "templates/web.html" %} +{% from "webshop/templates/includes/macros.html" import attribute_filter_section, field_filter_section, discount_range_filters %} + +{% block title %}{{ _("نتائج البحث") }}{% endblock %} + +{% block header %} +
{{ _("نتائج البحث عن: ") }} "{{ query or '' }}"
+{% endblock header %} + +{% block page_content %} +
+ +
+ +
+ + +
+
+
+
{{ _('Filters') }}
+ {{ _('Clear All') }} +
+ + {% if field_filters %} + {{ _(field_filter_section(field_filters)) }} + {% endif %} + + + {% if attribute_filters %} + {{ _(attribute_filter_section(attribute_filters)) }} + {% endif %} +
+
+
+ + + + + + + +{% endblock %} diff --git a/webshop/www/search.html b/webshop/www/search.html new file mode 100644 index 0000000000..fd07f101ef --- /dev/null +++ b/webshop/www/search.html @@ -0,0 +1,101 @@ +{% extends "templates/web.html" %} + +{% block title %}نتائج البحث{% endblock %} + +{% block page_content %} + + +
+

Search results for: ""

+
+
+ +{% endblock %} + +{% block script %} + +{% endblock %} \ No newline at end of file