diff --git a/website_sale_product_inquiry/README.rst b/website_sale_product_inquiry/README.rst new file mode 100644 index 0000000000..b3c0675928 --- /dev/null +++ b/website_sale_product_inquiry/README.rst @@ -0,0 +1,90 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============================ +Website Sale Product Inquiry +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:67a6973b26a2aa8a8020bba567ab27e04f818376baa9c0254cd48825b00fbcc9 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fe--commerce-lightgray.png?logo=github + :target: https://github.com/OCA/e-commerce/tree/19.0/website_sale_product_inquiry + :alt: OCA/e-commerce +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/e-commerce-19-0/e-commerce-19-0-website_sale_product_inquiry + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/e-commerce&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Adds a **Request Information** button to each product page in the +eCommerce shop. Clicking the button opens a modal form where the visitor +selects a request type (*More Information* or *Quote*), fills in their +contact details, and submits. + +A CRM lead is created automatically from the submission, pre-linked to +the enquired product. Downstream glue modules can override +``ProductInquiryController._get_product_salesperson`` to assign a +specific salesperson based on product-level ownership data. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ForgeFlow + +Contributors +------------ + +- `ForgeFlow `__: + + - Jasmin Solanki + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/e-commerce `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_sale_product_inquiry/__init__.py b/website_sale_product_inquiry/__init__.py new file mode 100644 index 0000000000..91c5580fed --- /dev/null +++ b/website_sale_product_inquiry/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/website_sale_product_inquiry/__manifest__.py b/website_sale_product_inquiry/__manifest__.py new file mode 100644 index 0000000000..f07c6f3e66 --- /dev/null +++ b/website_sale_product_inquiry/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Website Sale Product Inquiry", + "summary": "Add a product inquiry button and popup form on eCommerce product pages", + "version": "19.0.1.0.0", + "category": "Website", + "website": "https://github.com/OCA/e-commerce", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "website_sale", + "crm", + ], + "data": [ + "views/crm_lead_views.xml", + "views/templates.xml", + ], + "assets": { + "web.assets_frontend": [ + "website_sale_product_inquiry/static/src/js/product_inquiry.esm.js", + ], + }, +} diff --git a/website_sale_product_inquiry/controllers/__init__.py b/website_sale_product_inquiry/controllers/__init__.py new file mode 100644 index 0000000000..a005bfb146 --- /dev/null +++ b/website_sale_product_inquiry/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import main diff --git a/website_sale_product_inquiry/controllers/main.py b/website_sale_product_inquiry/controllers/main.py new file mode 100644 index 0000000000..8aebe6922f --- /dev/null +++ b/website_sale_product_inquiry/controllers/main.py @@ -0,0 +1,81 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import base64 + +from odoo import http +from odoo.http import request + + +class ProductInquiryController(http.Controller): + @http.route( + "/shop/product/inquiry", + type="http", + auth="public", + methods=["POST"], + website=True, + csrf=False, + ) + def product_inquiry(self, **kwargs): + product_id = kwargs.get("product_id", "").strip() + contact_name = kwargs.get("contact_name", "").strip() + email = kwargs.get("email", "").strip() + phone = kwargs.get("phone", "").strip() + company_name = kwargs.get("company_name", "").strip() + inquiry_type = kwargs.get("inquiry_type", "more_info") + question = kwargs.get("question", "").strip() + + if not product_id or not contact_name or not email or not question: + return request.make_json_response( + {"success": False, "error": "Required fields are missing."}, + status=400, + ) + try: + product_id = int(product_id) + except (ValueError, TypeError): + return request.make_json_response( + {"success": False, "error": "Invalid product."}, status=400 + ) + variant = request.env["product.product"].sudo().browse(product_id) + if not variant.exists(): + return request.make_json_response( + {"success": False, "error": "Product not found."}, status=404 + ) + description = f"

{question}

" + lead_vals = { + "name": f"Product inquiry: {variant.product_tmpl_id.name}", + "contact_name": contact_name, + "email_from": email, + "phone": phone or False, + "partner_name": company_name or False, + "inquired_product_id": variant.id, + "inquiry_type": inquiry_type, + "description": description, + "type": "lead", + "company_id": request.website.company_id.id, + } + salesperson_id = self._get_product_salesperson(variant) # pylint: disable=assignment-from-none + if salesperson_id is not None: + lead_vals["user_id"] = salesperson_id or False + lead = request.env["crm.lead"].sudo().create(lead_vals) + attachment_file = kwargs.get("attachment") + if attachment_file and hasattr(attachment_file, "read"): + data = attachment_file.read() + if data: + request.env["ir.attachment"].sudo().create( + { + "name": attachment_file.filename, + "datas": base64.b64encode(data), + "res_model": "crm.lead", + "res_id": lead.id, + } + ) + return request.make_json_response({"success": True}) + + def _get_product_salesperson(self, variant): + """Return salesperson assignment for the lead. + - Return a user ID to assign that user. + - Return False to explicitly leave user_id blank. + - Return None (default) to let the model default apply. + """ + return None diff --git a/website_sale_product_inquiry/models/__init__.py b/website_sale_product_inquiry/models/__init__.py new file mode 100644 index 0000000000..4ce1a3651b --- /dev/null +++ b/website_sale_product_inquiry/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import crm_lead diff --git a/website_sale_product_inquiry/models/crm_lead.py b/website_sale_product_inquiry/models/crm_lead.py new file mode 100644 index 0000000000..aa78ad4eee --- /dev/null +++ b/website_sale_product_inquiry/models/crm_lead.py @@ -0,0 +1,20 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class CrmLead(models.Model): + _inherit = "crm.lead" + + inquired_product_id = fields.Many2one( + "product.product", + string="Product", + ondelete="set null", + ) + inquiry_type = fields.Selection( + [ + ("more_info", "More Information"), + ("quote", "Quote"), + ], + ) diff --git a/website_sale_product_inquiry/pyproject.toml b/website_sale_product_inquiry/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/website_sale_product_inquiry/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_sale_product_inquiry/readme/CONTRIBUTORS.md b/website_sale_product_inquiry/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..1d59ed3fd4 --- /dev/null +++ b/website_sale_product_inquiry/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [ForgeFlow](https://www.forgeflow.com): + - Jasmin Solanki diff --git a/website_sale_product_inquiry/readme/DESCRIPTION.md b/website_sale_product_inquiry/readme/DESCRIPTION.md new file mode 100644 index 0000000000..37ed710414 --- /dev/null +++ b/website_sale_product_inquiry/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +Adds a **Request Information** button to each product page in the eCommerce shop. +Clicking the button opens a modal form where the visitor selects a request type +(*More Information* or *Quote*), fills in their contact details, and submits. + +A CRM lead is created automatically from the submission, pre-linked to the +enquired product. Downstream glue modules can override +`ProductInquiryController._get_product_salesperson` to assign a specific +salesperson based on product-level ownership data. diff --git a/website_sale_product_inquiry/static/description/index.html b/website_sale_product_inquiry/static/description/index.html new file mode 100644 index 0000000000..a9255376ae --- /dev/null +++ b/website_sale_product_inquiry/static/description/index.html @@ -0,0 +1,439 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Website Sale Product Inquiry

+ +

Beta License: AGPL-3 OCA/e-commerce Translate me on Weblate Try me on Runboat

+

Adds a Request Information button to each product page in the +eCommerce shop. Clicking the button opens a modal form where the visitor +selects a request type (More Information or Quote), fills in their +contact details, and submits.

+

A CRM lead is created automatically from the submission, pre-linked to +the enquired product. Downstream glue modules can override +ProductInquiryController._get_product_salesperson to assign a +specific salesperson based on product-level ownership data.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/e-commerce project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/website_sale_product_inquiry/static/src/js/product_inquiry.esm.js b/website_sale_product_inquiry/static/src/js/product_inquiry.esm.js new file mode 100644 index 0000000000..28088b476f --- /dev/null +++ b/website_sale_product_inquiry/static/src/js/product_inquiry.esm.js @@ -0,0 +1,112 @@ +/** @odoo-module */ + +import {Interaction} from "@web/public/interaction"; +import {registry} from "@web/core/registry"; + +class ProductInquiryInteraction extends Interaction { + static selector = "#productInquiryModal"; + + setup() { + this._variantRefs = {}; + (this.el.dataset.variantRefs || "").split(",").forEach((entry) => { + const sep = entry.indexOf(":"); + if (sep > 0) { + this._variantRefs[entry.slice(0, sep)] = entry.slice(sep + 1); + } + }); + + this._form = this.el.querySelector("#productInquiryForm"); + this._submitBtn = this.el.querySelector(".o_inquiry_submit"); + this._cancelBtn = this.el.querySelector(".o_inquiry_cancel"); + this._errorDiv = this.el.querySelector(".o_inquiry_error"); + this._successDiv = this.el.querySelector(".o_inquiry_success"); + this._footer = this.el.querySelector(".modal-footer"); + this._itemNoSpan = this.el.querySelector(".o_inquiry_item_no"); + this._productIdInput = this.el.querySelector(".o_inquiry_product_id"); + + this.el.addEventListener("show.bs.modal", () => this._onShow()); + this.el.addEventListener("hidden.bs.modal", () => this._onHidden()); + this._form.addEventListener("submit", (ev) => this._onSubmit(ev)); + this._form.addEventListener("input", (ev) => this._onInput(ev)); + } + + _onInput(ev) { + if (ev.target.validity.valid) { + ev.target.classList.remove("is-invalid"); + } + } + + _onShow() { + const pageProductInput = document.querySelector( + 'form:not(#productInquiryForm) input[name="product_id"]' + ); + if (!pageProductInput || !pageProductInput.value) return; + const variantId = pageProductInput.value; + if (this._productIdInput) { + this._productIdInput.value = variantId; + } + if (this._itemNoSpan) { + const ref = this._variantRefs[variantId]; + if (ref !== undefined) { + this._itemNoSpan.textContent = ref; + } + } + } + + _setButtons(disabled) { + this._submitBtn.disabled = disabled; + this._cancelBtn.disabled = disabled; + } + + _showError(message) { + this._errorDiv.textContent = message; + this._errorDiv.classList.remove("d-none"); + this._setButtons(false); + } + + async _onSubmit(ev) { + ev.preventDefault(); + if (!this._form.checkValidity()) { + this._form.querySelectorAll(":invalid").forEach((el) => { + el.classList.add("is-invalid"); + }); + return; + } + this._setButtons(true); + this._errorDiv.classList.add("d-none"); + + try { + const response = await fetch("/shop/product/inquiry", { + method: "POST", + body: new FormData(this._form), + }); + const data = await response.json(); + if (data.success) { + this._form.classList.add("d-none"); + this._successDiv.classList.remove("d-none"); + this._footer.classList.add("d-none"); + } else { + this._showError(data.error || "An error occurred. Please try again."); + } + } catch { + this._showError("An error occurred. Please try again."); + } + } + + _onHidden() { + this._form.reset(); + this._form + .querySelectorAll(".is-invalid") + .forEach((el) => el.classList.remove("is-invalid")); + this._form.classList.remove("d-none"); + this._errorDiv.classList.add("d-none"); + this._errorDiv.textContent = ""; + this._successDiv.classList.add("d-none"); + this._footer.classList.remove("d-none"); + this._setButtons(false); + } +} + +registry + .category("public.interactions") + .add("website_sale_product_inquiry.inquiry", ProductInquiryInteraction); diff --git a/website_sale_product_inquiry/views/crm_lead_views.xml b/website_sale_product_inquiry/views/crm_lead_views.xml new file mode 100644 index 0000000000..6ea71580d1 --- /dev/null +++ b/website_sale_product_inquiry/views/crm_lead_views.xml @@ -0,0 +1,16 @@ + + + + crm.lead.form.product.inquiry + crm.lead + + + + + + + + + + + diff --git a/website_sale_product_inquiry/views/templates.xml b/website_sale_product_inquiry/views/templates.xml new file mode 100644 index 0000000000..837290cf7b --- /dev/null +++ b/website_sale_product_inquiry/views/templates.xml @@ -0,0 +1,237 @@ + + + + + + +