diff --git a/webshop/hooks.py b/webshop/hooks.py index ed854d07cd..fe844110e4 100644 --- a/webshop/hooks.py +++ b/webshop/hooks.py @@ -10,7 +10,7 @@ required_apps = ["payments", "erpnext"] -web_include_css = "webshop-web.bundle.css" +web_include_css = ["webshop-web.bundle.css", "/assets/webshop/css/webshop-modern.css"] web_include_js = "web.bundle.js" @@ -26,6 +26,11 @@ website_generators = ["Website Item", "Item Group"] +website_redirects = [ + {"source": "/index", "target": "/"}, + {"source": "/products/freshmate", "target": "/storage/freshmate"}, +] + override_doctype_class = { "Payment Request": "webshop.webshop.doctype.override_doctype.payment_request.PaymentRequest", "Item Group": "webshop.webshop.doctype.override_doctype.item_group.WebshopItemGroup", @@ -72,7 +77,139 @@ }, } +scheduler_events = { + "hourly": [ + "webshop.webshop.engagement.send_abandoned_cart_reminders", + ] +} + + 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" } + +custom_fields = { + "Sales Order": [ + { + "fieldname": "payment_method", + "label": "Payment Method", + "fieldtype": "Data", + "insert_after": "payment_gateway_account", + "read_only": 1, + "in_list_view": 1 + }, + { + "fieldname": "payment_review_section", + "label": "Webshop Payment Review", + "fieldtype": "Section Break", + "insert_after": "payment_method", + "collapsible": 1 + }, + { + "fieldname": "requires_payment_review", + "label": "Requires Payment Review", + "fieldtype": "Check", + "insert_after": "payment_review_section", + "read_only": 1, + "in_list_view": 1 + }, + { + "fieldname": "payment_review_status", + "label": "Payment Review Status", + "fieldtype": "Select", + "options": "\nPending Verification\nApproved\nRejected", + "insert_after": "requires_payment_review", + "read_only": 1, + "in_list_view": 1 + }, + { + "fieldname": "payment_receipt", + "label": "Payment Receipt", + "fieldtype": "Attach", + "insert_after": "payment_review_status", + "read_only": 1 + }, + { + "fieldname": "payment_review_notes", + "label": "Payment Review Notes", + "fieldtype": "Small Text", + "insert_after": "payment_receipt" + }, + { + "fieldname": "payment_review_column_break", + "fieldtype": "Column Break", + "insert_after": "payment_review_notes" + }, + { + "fieldname": "payment_reviewed_by", + "label": "Payment Reviewed By", + "fieldtype": "Link", + "options": "User", + "insert_after": "payment_review_column_break", + "read_only": 1 + }, + { + "fieldname": "payment_reviewed_on", + "label": "Payment Reviewed On", + "fieldtype": "Datetime", + "insert_after": "payment_reviewed_by", + "read_only": 1 + } + ], + "Webshop Settings": [ + { + "fieldname": "conversion_section", + "label": "Conversion Boosters", + "fieldtype": "Section Break", + "insert_after": "redirect_on_action", + "collapsible": 1 + }, + { + "fieldname": "delivery_promise_text", + "label": "Delivery Promise Text", + "fieldtype": "Data", + "insert_after": "conversion_section", + "default": "Delivery in 2-4 working days" + }, + { + "fieldname": "returns_promise_text", + "label": "Returns / Support Promise Text", + "fieldtype": "Data", + "insert_after": "delivery_promise_text", + "default": "Easy exchange and support after purchase" + }, + { + "fieldname": "first_order_coupon_code", + "label": "First Order Coupon Code", + "fieldtype": "Data", + "insert_after": "returns_promise_text" + }, + { + "fieldname": "first_order_coupon_text", + "label": "First Order Coupon Text", + "fieldtype": "Data", + "insert_after": "first_order_coupon_code", + "default": "Use this code on your first order" + }, + { + "fieldname": "conversion_column_break", + "fieldtype": "Column Break", + "insert_after": "first_order_coupon_text" + }, + { + "fieldname": "low_stock_threshold", + "label": "Low Stock Threshold", + "fieldtype": "Int", + "insert_after": "conversion_column_break", + "default": "5" + }, + { + "fieldname": "enable_exit_intent_recovery", + "label": "Enable Exit Intent Cart Recovery", + "fieldtype": "Check", + "insert_after": "low_stock_threshold", + "default": "1" + } + ] +} diff --git a/webshop/patches.txt b/webshop/patches.txt index 5c7abf22a9..bc0f3824f4 100644 --- a/webshop/patches.txt +++ b/webshop/patches.txt @@ -4,4 +4,10 @@ webshop.patches.add_homepage_field #09-05-2024 webshop.patches.enable_allow_to_guest_view_for_item_group -webshop.patches.clear_cache_for_item_group_route \ No newline at end of file +webshop.patches.clear_cache_for_item_group_route +webshop.patches.add_payment_review_fields +webshop.patches.add_website_item_badge_fields +webshop.patches.add_abandoned_cart_fields +webshop.patches.add_conversion_settings_fields +webshop.patches.enable_website_item_guest_view +webshop.patches.unpublish_placeholder_freshmate_page diff --git a/webshop/patches/add_abandoned_cart_fields.py b/webshop/patches/add_abandoned_cart_fields.py new file mode 100644 index 0000000000..776d0a1219 --- /dev/null +++ b/webshop/patches/add_abandoned_cart_fields.py @@ -0,0 +1,41 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + + +def execute(): + create_custom_fields( + { + "Quotation": [ + { + "fieldname": "abandoned_cart_section", + "label": "Abandoned Cart Recovery", + "fieldtype": "Section Break", + "insert_after": "contact_email", + "collapsible": 1, + }, + { + "fieldname": "abandoned_cart_reminder_sent", + "label": "Abandoned Cart Reminder Sent", + "fieldtype": "Check", + "insert_after": "abandoned_cart_section", + "read_only": 1, + }, + { + "fieldname": "abandoned_cart_last_reminder_on", + "label": "Last Reminder On", + "fieldtype": "Datetime", + "insert_after": "abandoned_cart_reminder_sent", + "read_only": 1, + }, + { + "fieldname": "abandoned_cart_reminder_count", + "label": "Reminder Count", + "fieldtype": "Int", + "insert_after": "abandoned_cart_last_reminder_on", + "read_only": 1, + }, + ] + }, + ignore_validate=True, + ) + frappe.clear_cache(doctype="Quotation") diff --git a/webshop/patches/add_conversion_settings_fields.py b/webshop/patches/add_conversion_settings_fields.py new file mode 100644 index 0000000000..cac886ae47 --- /dev/null +++ b/webshop/patches/add_conversion_settings_fields.py @@ -0,0 +1,69 @@ +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +import frappe + + +def get_fields(): + return { + "Webshop Settings": [ + { + "fieldname": "conversion_section", + "label": "Conversion Boosters", + "fieldtype": "Section Break", + "insert_after": "redirect_on_action", + "collapsible": 1, + }, + { + "fieldname": "delivery_promise_text", + "label": "Delivery Promise Text", + "fieldtype": "Data", + "insert_after": "conversion_section", + "default": "Delivery in 2-4 working days", + }, + { + "fieldname": "returns_promise_text", + "label": "Returns / Support Promise Text", + "fieldtype": "Data", + "insert_after": "delivery_promise_text", + "default": "Easy exchange and support after purchase", + }, + { + "fieldname": "first_order_coupon_code", + "label": "First Order Coupon Code", + "fieldtype": "Data", + "insert_after": "returns_promise_text", + }, + { + "fieldname": "first_order_coupon_text", + "label": "First Order Coupon Text", + "fieldtype": "Data", + "insert_after": "first_order_coupon_code", + "default": "Use this code on your first order", + }, + { + "fieldname": "conversion_column_break", + "fieldtype": "Column Break", + "insert_after": "first_order_coupon_text", + }, + { + "fieldname": "low_stock_threshold", + "label": "Low Stock Threshold", + "fieldtype": "Int", + "insert_after": "conversion_column_break", + "default": "5", + }, + { + "fieldname": "enable_exit_intent_recovery", + "label": "Enable Exit Intent Cart Recovery", + "fieldtype": "Check", + "insert_after": "low_stock_threshold", + "default": "1", + }, + ] + } + + +def execute(): + create_custom_fields(get_fields(), ignore_validate=True) + if frappe.db.get_single_value("Webshop Settings", "enable_exit_intent_recovery") is None: + frappe.db.set_single_value("Webshop Settings", "enable_exit_intent_recovery", 1) + frappe.clear_cache(doctype="Webshop Settings") diff --git a/webshop/patches/add_payment_review_fields.py b/webshop/patches/add_payment_review_fields.py new file mode 100644 index 0000000000..ac1a0d49bb --- /dev/null +++ b/webshop/patches/add_payment_review_fields.py @@ -0,0 +1,78 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + + +def execute(): + create_custom_fields( + { + "Sales Order": [ + { + "fieldname": "payment_method", + "label": "Payment Method", + "fieldtype": "Data", + "insert_after": "currency", + "read_only": 1, + "in_list_view": 1, + }, + { + "fieldname": "payment_review_section", + "label": "Webshop Payment Review", + "fieldtype": "Section Break", + "insert_after": "payment_method", + "collapsible": 1, + }, + { + "fieldname": "requires_payment_review", + "label": "Requires Payment Review", + "fieldtype": "Check", + "insert_after": "payment_review_section", + "read_only": 1, + "in_list_view": 1, + }, + { + "fieldname": "payment_review_status", + "label": "Payment Review Status", + "fieldtype": "Select", + "options": "\nPending Verification\nApproved\nRejected", + "insert_after": "requires_payment_review", + "read_only": 1, + "in_list_view": 1, + }, + { + "fieldname": "payment_receipt", + "label": "Payment Receipt", + "fieldtype": "Attach", + "insert_after": "payment_review_status", + "read_only": 1, + }, + { + "fieldname": "payment_review_notes", + "label": "Payment Review Notes", + "fieldtype": "Small Text", + "insert_after": "payment_receipt", + }, + { + "fieldname": "payment_review_column_break", + "fieldtype": "Column Break", + "insert_after": "payment_review_notes", + }, + { + "fieldname": "payment_reviewed_by", + "label": "Payment Reviewed By", + "fieldtype": "Link", + "options": "User", + "insert_after": "payment_review_column_break", + "read_only": 1, + }, + { + "fieldname": "payment_reviewed_on", + "label": "Payment Reviewed On", + "fieldtype": "Datetime", + "insert_after": "payment_reviewed_by", + "read_only": 1, + }, + ] + }, + ignore_validate=True, + ) + frappe.clear_cache(doctype="Sales Order") diff --git a/webshop/patches/add_website_item_badge_fields.py b/webshop/patches/add_website_item_badge_fields.py new file mode 100644 index 0000000000..1db5eeab80 --- /dev/null +++ b/webshop/patches/add_website_item_badge_fields.py @@ -0,0 +1,41 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + + +def execute(): + create_custom_fields( + { + "Website Item": [ + { + "fieldname": "badge_section", + "label": "Product Badge", + "fieldtype": "Section Break", + "insert_after": "slideshow", + "collapsible": 1, + }, + { + "fieldname": "badge_label", + "label": "Badge Label", + "fieldtype": "Data", + "insert_after": "badge_section", + "description": "Example: New Arrival, Bestseller, Food Grade, Sale", + }, + { + "fieldname": "badge_color", + "label": "Badge Color", + "fieldtype": "Color", + "insert_after": "badge_label", + "default": "#B0008E", + }, + { + "fieldname": "badge_priority", + "label": "Badge Priority", + "fieldtype": "Int", + "insert_after": "badge_color", + "description": "Higher priority badges can be used for sorting or future campaigns.", + }, + ] + }, + ignore_validate=True, + ) + frappe.clear_cache(doctype="Website Item") diff --git a/webshop/patches/enable_website_item_guest_view.py b/webshop/patches/enable_website_item_guest_view.py new file mode 100644 index 0000000000..005ed113c0 --- /dev/null +++ b/webshop/patches/enable_website_item_guest_view.py @@ -0,0 +1,15 @@ +import frappe +from frappe.custom.doctype.property_setter.property_setter import make_property_setter + + +def execute(): + frappe.reload_doc("webshop", "doctype", "website_item") + make_property_setter( + "Website Item", + "", + "allow_guest_to_view", + 1, + "Check", + for_doctype=True, + validate_fields_for_doctype=False, + ) diff --git a/webshop/patches/unpublish_placeholder_freshmate_page.py b/webshop/patches/unpublish_placeholder_freshmate_page.py new file mode 100644 index 0000000000..6e54c7b14e --- /dev/null +++ b/webshop/patches/unpublish_placeholder_freshmate_page.py @@ -0,0 +1,7 @@ +import frappe + + +def execute(): + page = frappe.db.get_value("Web Page", {"route": "products/freshmate"}, "name") + if page: + frappe.db.set_value("Web Page", page, "published", 0) diff --git a/webshop/public/css/webshop-modern.css b/webshop/public/css/webshop-modern.css new file mode 100644 index 0000000000..9333a3cff9 --- /dev/null +++ b/webshop/public/css/webshop-modern.css @@ -0,0 +1,344 @@ +/* Modern Webshop UI - Design System */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap'); + +:root { + /* Colors */ + --ws-primary-h: 220; + --ws-primary-s: 100%; + --ws-primary-l: 50%; + --ws-primary: hsl(var(--ws-primary-h), var(--ws-primary-s), var(--ws-primary-l)); + --ws-primary-hover: hsl(var(--ws-primary-h), var(--ws-primary-s), 40%); + --ws-primary-light: hsl(var(--ws-primary-h), var(--ws-primary-s), 95%); + + --ws-bg: hsl(220, 20%, 98%); + --ws-surface: hsl(0, 0%, 100%); + --ws-border: hsl(220, 10%, 90%); + --ws-border-hover: hsl(220, 10%, 80%); + + --ws-text: hsl(220, 30%, 12%); + --ws-text-muted: hsl(220, 15%, 45%); + --ws-text-light: hsl(220, 10%, 65%); + + --ws-success: hsl(145, 60%, 45%); + --ws-warning: hsl(45, 90%, 50%); + --ws-error: hsl(0, 80%, 55%); + + /* Shadows */ + --ws-shadow-sm: 0 2px 4px rgba(0,0,0,0.02), 0 1px 2px rgba(0,0,0,0.04); + --ws-shadow-md: 0 4px 12px rgba(0,0,0,0.05), 0 2px 4px rgba(0,0,0,0.05); + --ws-shadow-lg: 0 12px 24px rgba(0,0,0,0.08), 0 4px 8px rgba(0,0,0,0.05); + + /* Radii */ + --ws-radius-sm: 6px; + --ws-radius-md: 10px; + --ws-radius-lg: 16px; + --ws-radius-full: 9999px; + + /* Transitions */ + --ws-ease: cubic-bezier(0.4, 0, 0.2, 1); + --ws-transition: all 0.3s var(--ws-ease); +} + +/* Global Styles */ +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + background-color: var(--ws-bg); + color: var(--ws-text); + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, h4, .product-title { + font-family: 'Outfit', sans-serif; + font-weight: 600; +} + +/* Utility Classes */ +.ws-glass { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.ws-shadow { + box-shadow: var(--ws-shadow-md); + transition: var(--ws-transition); +} + +.ws-shadow:hover { + box-shadow: var(--ws-shadow-lg); + transform: translateY(-2px); +} + +.ws-card { + background: var(--ws-surface); + border: 1px solid var(--ws-border); + border-radius: var(--ws-radius-md); + overflow: hidden; +} + +/* Base Overrides */ +.btn-primary { + background-color: var(--ws-primary) !important; + border-color: var(--ws-primary) !important; + border-radius: var(--ws-radius-sm); + font-weight: 500; + padding: 0.6rem 1.2rem; + transition: var(--ws-transition); +} + +.btn-primary:hover { + background-color: var(--ws-primary-hover) !important; + transform: translateY(-1px); + box-shadow: 0 4px 12px hsla(var(--ws-primary-h), var(--ws-primary-s), var(--ws-primary-l), 0.3); +} + +/* Form Controls */ +.form-control { + border: 1px solid var(--ws-border); + border-radius: var(--ws-radius-sm); + padding: 0.6rem 1rem; + transition: var(--ws-transition); +} + +.form-control:focus { + border-color: var(--ws-primary); + box-shadow: 0 0 0 4px hsla(var(--ws-primary-h), var(--ws-primary-s), var(--ws-primary-l), 0.1); +} + +/* Item Card Overrides */ +.item-card .card { + border: none; + background: var(--ws-surface); + border-radius: var(--ws-radius-lg); + box-shadow: var(--ws-shadow-sm); + transition: var(--ws-transition); + overflow: hidden; +} + +.item-card .card:hover { + box-shadow: var(--ws-shadow-lg); + transform: translateY(-4px); +} + +.item-card .card-img-container { + padding: 1.5rem; + background: var(--ws-primary-light); + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; +} + +.item-card .card-img { + mix-blend-mode: multiply; + max-height: 180px; + object-fit: contain; +} + +.item-card .card-body { + padding: 1.5rem; +} + +.item-card .product-title { + font-size: 1.1rem; + color: var(--ws-text); + margin-bottom: 0.5rem; + transition: var(--ws-transition); +} + +.item-card .product-title:hover { + color: var(--ws-primary); +} + +.item-card .product-category { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ws-text-light); + font-weight: 600; +} + +/* Toolbar & Filters */ +.toolbar { + background: var(--ws-surface); + padding: 1rem; + border-radius: var(--ws-radius-md); + box-shadow: var(--ws-shadow-sm); + margin-bottom: 2rem; +} + +.search-icon { + position: absolute; + right: 15px; + top: 50%; + transform: translateY(-50%); + color: var(--ws-text-light); + pointer-events: none; +} + +#search-box { + border-radius: var(--ws-radius-full); + padding-left: 1.5rem; + padding-right: 3rem; +} + +/* Side Drawer Styles */ +html, +body { + overflow-x: hidden; +} + +.ws-drawer { + position: fixed; + top: 0; + right: 0; + width: 450px; + height: 100vh; + background: var(--ws-surface); + z-index: 1100; /* Higher than standard modals */ + transform: translateX(100%); + transition: transform 0.4s var(--ws-ease); + display: flex; + flex-direction: column; + max-width: 100%; + margin: 0 !important; /* Prevent center alignment */ +} + +.ws-drawer.active { + transform: translateX(0); +} + +.ws-drawer.open { + transform: translateX(0); +} + +.ws-drawer-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + z-index: 1090; + opacity: 0; + visibility: hidden; + transition: var(--ws-transition); +} + +.ws-drawer-backdrop.open { + opacity: 1; + visibility: visible; +} + +/* Scrollbar for Drawer */ +.ws-drawer-body::-webkit-scrollbar { + width: 6px; +} +.ws-drawer-body::-webkit-scrollbar-thumb { + background: var(--ws-border); + border-radius: 10px; +} + +.cart-drawer-close { + cursor: pointer; + background: none; + border: none; + color: var(--ws-text-muted); +} + +.cart-drawer-close:hover { + color: var(--ws-text); +} + +@media (max-width: 576px) { + .ws-drawer { + width: 100%; + right: 0; + } + .ws-drawer.open { + transform: translateX(0); + } + .ws-drawer.active { + transform: translateX(0); + } +} + +/* Product Detail Actions */ +.ws-qty-selector { + background: rgba(var(--ws-bg-rgb), 0.5); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(var(--ws-text-rgb), 0.08); + display: flex; + align-items: center; + justify-content: space-between; +} + +.ws-qty-selector .qty-input { + background: transparent; + border: none; + text-align: center; + font-weight: 700; + width: 50px; + color: var(--ws-text); +} + +.ws-qty-selector button { + background: transparent; + border: none; + padding: 0 15px; + font-size: 1.25rem; + color: var(--ws-text); + transition: var(--ws-transition); +} + +.ws-qty-selector button:hover { + color: var(--ws-primary); + transform: scale(1.1); +} + +.btn-add-to-cart { + background: rgba(var(--ws-bg-rgb), 0.8) !important; + backdrop-filter: blur(12px); + border: 1px solid rgba(var(--ws-text-rgb), 0.1) !important; + color: var(--ws-text) !important; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + transition: var(--ws-transition); +} + +.btn-add-to-cart:hover { + background: var(--ws-surface) !important; + border-color: var(--ws-primary) !important; + color: var(--ws-primary) !important; + transform: translateY(-2px); + box-shadow: var(--ws-shadow-lg); +} + +.btn-buy-now { + background: var(--ws-primary) !important; + border: none !important; + color: white !important; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + transition: var(--ws-transition); +} + +.btn-buy-now:hover { + background: var(--ws-primary-hover) !important; + transform: translateY(-2px); + box-shadow: 0 10px 20px hsla(var(--ws-primary-h), var(--ws-primary-s), var(--ws-primary-l), 0.3); +} + +/* RGB Helper Variables (for rgba usage) */ +:root { + --ws-text-rgb: 18, 22, 28; /* hsl(220, 30%, 12%) */ + --ws-bg-rgb: 247, 248, 249; /* hsl(220, 20%, 98%) */ +} diff --git a/webshop/public/js/conversion_boosters.js b/webshop/public/js/conversion_boosters.js new file mode 100644 index 0000000000..8d56fba0e2 --- /dev/null +++ b/webshop/public/js/conversion_boosters.js @@ -0,0 +1,244 @@ +frappe.provide("webshop.webshop.conversion"); + +webshop.webshop.conversion = { + settings: null, + + init() { + this.inject_styles(); + this.fetch_settings(); + this.bind_coupon_copy(); + this.bind_whatsapp_assist(); + this.setup_product_page(); + this.setup_exit_intent(); + }, + + inject_styles() { + if ($("#conversion-booster-styles").length) return; + $("head").append(` + + `); + }, + + fetch_settings() { + frappe.call({ + method: "webshop.webshop.api.get_conversion_settings", + callback: (r) => { + this.settings = r.message || {}; + } + }); + }, + + escape_html(value) { + if (frappe.utils && frappe.utils.escape_html) { + return frappe.utils.escape_html(String(value || "")); + } + return $("
").text(value || "").html(); + }, + + escape_url(value) { + return this.escape_html(encodeURI(value || "#")); + }, + + setup_product_page() { + const $cart = $(".item-cart[data-variant-item-code]"); + if (!$cart.length) return; + + const item_code = $cart.data("variant-item-code"); + this.track_recently_viewed(item_code); + this.render_sticky_buy_bar(); + this.render_review_summary(item_code); + this.render_recently_viewed(item_code); + this.render_recommendations(item_code); + }, + + track_recently_viewed(item_code) { + if (!item_code) return; + let items = []; + try { + items = JSON.parse(localStorage.getItem("recently_viewed_items") || "[]"); + } catch (e) { + items = []; + } + items = items.filter((code) => code !== item_code); + items.unshift(item_code); + localStorage.setItem("recently_viewed_items", JSON.stringify(items.slice(0, 12))); + }, + + render_sticky_buy_bar() { + if ($("#ws-sticky-buy-bar").length || !$(".btn-buy-now").length) return; + + const title = $(".product-title, h1[itemprop='name'], .page-title").first().text().trim() || document.title; + const price = $(".product-price-section .h1").first().text().trim(); + const html = ` +
+
+
${this.escape_html(title)}
+ ${price ? `
${this.escape_html(price)}
` : ""} +
+ + +
+ `; + $("body").append(html); + $(".ws-sticky-add").on("click", () => $(".btn-add-to-cart").first().trigger("click")); + $(".ws-sticky-buy").on("click", () => $(".btn-buy-now").first().trigger("click")); + }, + + render_review_summary(item_code) { + const $target = $("#conversion-review-summary"); + if (!$target.length || !item_code) return; + + frappe.call({ + method: "webshop.webshop.api.get_item_review_summary", + args: { item_code }, + callback: (r) => { + const summary = r.message || {}; + if (!summary.total_reviews) { + $target.html(`${__("Be the first to review this product")}`); + return; + } + const rating = Number(summary.average_rating || 0).toFixed(1); + $target.html(` + + ★★★★★ + ${this.escape_html(rating)} + (${this.escape_html(summary.total_reviews)} ${__("reviews")}) + + `); + } + }); + }, + + render_recently_viewed(current_item_code) { + const $target = $("#recently-viewed-products"); + if (!$target.length) return; + + let item_codes = []; + try { + item_codes = JSON.parse(localStorage.getItem("recently_viewed_items") || "[]"); + } catch (e) { + item_codes = []; + } + item_codes = item_codes.filter((code) => code && code !== current_item_code).slice(0, 4); + if (!item_codes.length) return; + + frappe.call({ + method: "webshop.webshop.api.get_recently_viewed_items", + args: { item_codes }, + callback: (r) => this.render_product_strip($target, __("Recently Viewed"), r.message || []) + }); + }, + + render_recommendations(item_code) { + const $target = $("#smart-recommendations"); + if (!$target.length) return; + + frappe.call({ + method: "webshop.webshop.api.get_smart_recommendations", + args: { item_code, limit: 4 }, + callback: (r) => this.render_product_strip($target, __("You May Also Like"), r.message || []) + }); + }, + + render_product_strip($target, title, items) { + if (!items.length) return; + const cards = items.map((item) => { + const image = item.thumbnail || item.website_image || "/assets/webshop/images/cart-empty-state.png"; + const rating = item.review_summary && item.review_summary.total_reviews + ? `
★★★★★ ${this.escape_html(Number(item.review_summary.average_rating || 0).toFixed(1))}
` + : ""; + return ` + +
${this.escape_html(item.web_item_name || item.item_name)}
+
${this.escape_html(item.web_item_name || item.item_name)}
+ ${rating} + ${item.formatted_price ? `
${this.escape_html(item.formatted_price)}
` : ""} +
+ `; + }).join(""); + + $target.html(` +
+
${this.escape_html(title)}
+
${cards}
+
+ `); + }, + + bind_coupon_copy() { + $(document).on("click", ".btn-copy-conversion-coupon", function() { + const code = $(this).data("coupon"); + if (!code) return; + navigator.clipboard && navigator.clipboard.writeText(code); + frappe.show_alert({ message: __("Coupon copied"), indicator: "green" }); + $(".txtcoupon").val(code); + }); + }, + + bind_whatsapp_assist() { + $(document).on("click", ".btn-whatsapp-cart-assist", function() { + const $btn = $(this); + $btn.prop("disabled", true); + frappe.call({ + method: "webshop.webshop.api.get_cart_whatsapp_assist_url", + callback: (r) => { + $btn.prop("disabled", false); + if (r.message && r.message.url) { + window.open(r.message.url, "_blank", "noopener"); + } + }, + error: () => $btn.prop("disabled", false) + }); + }); + }, + + setup_exit_intent() { + let shown = false; + const maybe_show = () => { + if (shown || sessionStorage.getItem("conversion_exit_prompt_seen")) return; + const cart_count = parseInt(frappe.get_cookie("cart_count") || 0); + if (!cart_count || (this.settings && !this.settings.enable_exit_intent_recovery)) return; + + shown = true; + sessionStorage.setItem("conversion_exit_prompt_seen", "1"); + frappe.confirm( + __("Want help completing your cart on WhatsApp?"), + () => $(".btn-whatsapp-cart-assist").first().trigger("click"), + () => {} + ); + }; + + $(document).on("mouseleave", (e) => { + if (e.clientY <= 0) maybe_show(); + }); + } +}; + +frappe.ready(() => webshop.webshop.conversion.init()); diff --git a/webshop/public/js/product_ui/grid.js b/webshop/public/js/product_ui/grid.js index 0212d823a2..da8ea09cb5 100644 --- a/webshop/public/js/product_ui/grid.js +++ b/webshop/public/js/product_ui/grid.js @@ -1,3 +1,19 @@ +const ws_escape_html = (value) => { + if (frappe.utils && frappe.utils.escape_html) { + return frappe.utils.escape_html(String(value || "")); + } + return $("
").text(value || "").html(); +}; + +const ws_escape_url = (value) => ws_escape_html(encodeURI(value || "#")); + +const ws_badge_html = (item) => { + if (!item.badge_label) return ""; + const label = ws_escape_html(item.badge_label); + const color = ws_escape_html(item.badge_color || "#B0008E"); + return `${label}`; +}; + webshop.ProductGrid = class { /* Options: - items: Items @@ -23,10 +39,11 @@ webshop.ProductGrid = class { 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; + let safe_title = ws_escape_html(title); html += `
`; - html += me.get_image_html(item, title); - html += me.get_card_body_html(item, title, me.settings); + html += me.get_image_html(item, title, safe_title); + html += me.get_card_body_html(item, title, safe_title, me.settings); html += `
`; }); @@ -34,23 +51,24 @@ webshop.ProductGrid = class { $product_wrapper.append(html); } - get_image_html(item, title) { - let image = item.website_image; + get_image_html(item, title, safe_title) { + let image = ws_escape_url(item.website_image); + let route = ws_escape_url(item.route); - if (image) { + if (item.website_image) { return ` `; } else { return ` @@ -58,12 +76,12 @@ webshop.ProductGrid = class { } } - get_card_body_html(item, title, settings) { + get_card_body_html(item, title, safe_title, settings) { let body_html = `
`; - body_html += this.get_title(item, title); + body_html += this.get_title(item, safe_title); // get floating elements if (!item.has_variants) { @@ -77,7 +95,8 @@ webshop.ProductGrid = class { } body_html += `
`; - body_html += `
${ item.item_group || '' }
`; + body_html += ws_badge_html(item); + body_html += `
${ ws_escape_html(item.item_group) }
`; if (item.formatted_price) { body_html += this.get_price_html(item); @@ -91,8 +110,9 @@ webshop.ProductGrid = class { } get_title(item, title) { + let route = ws_escape_url(item.route); let title_html = ` - +
${ title || '' }
@@ -103,9 +123,10 @@ webshop.ProductGrid = class { get_wishlist_icon(item) { let icon_class = item.wished ? "wished" : "not-wished"; + let item_code = ws_escape_html(item.item_code); return `