diff --git a/addons/point_of_sale/static/src/app/customer_display_service.js b/addons/point_of_sale/static/src/app/customer_display_service.js
new file mode 100644
index 00000000000..6555b1be8de
--- /dev/null
+++ b/addons/point_of_sale/static/src/app/customer_display_service.js
@@ -0,0 +1,146 @@
+/** @odoo-module */
+
+import { effect, Reactive } from "@point_of_sale/utils";
+import { registry } from "@web/core/registry";
+import { pick } from "@web/core/utils/objects";
+
+export class LocalDisplay extends Reactive {
+ status = "success";
+ constructor() {
+ super();
+ this.setup(...arguments);
+ }
+ setup(globalState) {
+ this.globalState = globalState;
+ }
+ async connect() {
+ this.popupWindow = window.open("", "Customer Display", "height=600,width=900");
+ this.update({ refreshResources: true });
+ }
+ async update({ refreshResources = false } = {}) {
+ if (!this.popupWindow || this.popupWindow.closed) {
+ return;
+ }
+ // TODO: this could probably be improved by loading owl in the popup window,
+ // making it render the customer display, and simply sending messages to
+ // update the state, instead of sending HTML which causes jankiness with
+ // animations.
+ const { body: displayBody, head: displayHead } = this.popupWindow.document;
+ const container = document.createElement("div");
+ container.innerHTML = await this.globalState.customerDisplayHTML();
+
+ if (refreshResources) {
+ displayHead.textContent = "";
+ displayHead.appendChild(container.querySelector(".resources"));
+ }
+
+ displayBody.textContent = "";
+ displayBody.appendChild(container.querySelector(".pos-customer_facing_display"));
+
+ const orderLines = displayBody.querySelector(".pos_orderlines_list");
+ orderLines.scrollTop = orderLines.scrollHeight;
+ }
+}
+
+export class RemoteDisplay extends Reactive {
+ static serviceDependencies = ["hardware_proxy"];
+ status = "failure";
+ constructor() {
+ super();
+ this.setup(...arguments);
+ }
+ setup(globalState, { hardware_proxy }) {
+ this.hardwareProxy = hardware_proxy;
+ this.globalState = globalState;
+ this.updateStatus();
+ }
+ async connect() {
+ const html = await this.globalState.customerDisplayHTML();
+ try {
+ const { status } = await this.hardwareProxy.message("take_control", { html });
+ this.status = status === "success" ? "success" : "warning";
+ this.updateStatus();
+ } catch (error) {
+ this.status = error === undefined ? "failure" : "not_found";
+ }
+ }
+ async update() {
+ const html = await this.globalState.customerDisplayHTML();
+ if (this.isUpdatingStatus && this.hardwareProxy.connectionInfo.status === "connected") {
+ return this.hardwareProxy.message("customer_facing_display", { html });
+ }
+ }
+ async updateStatus() {
+ if (!this.hardwareProxy.host || this.isUpdatingStatus) {
+ return;
+ }
+ this.isUpdatingStatus = true;
+ while (this.isUpdatingStatus) {
+ try {
+ const { status } = await this.hardwareProxy.message("test_ownership");
+ this.status = status === "OWNER" ? "success" : "warning";
+ } catch (error) {
+ if (error === undefined) {
+ // FIXME POSREF when does this happen?
+ this.status = "failure";
+ } else {
+ this.status = "not_found";
+ this.isUpdatingStatus = false;
+ return;
+ }
+ }
+ await new Promise((resolve) => setTimeout(resolve, 3000));
+ }
+ }
+}
+
+export const customerDisplayService = {
+ get dependencies() {
+ // getter so thet the RemoteDisplay dependencies are resolved when services start
+ // and not at module definition time, this allows patches to be accounted for
+ return ["pos", ...RemoteDisplay.serviceDependencies];
+ },
+ start(env, deps) {
+ const { globalState } = deps.pos;
+
+ // Dummy service, does nothing until the globalState is ready, then the methods get replaced
+ const service = {
+ connect() {},
+ };
+
+ globalState.ready.then(() => {
+ const {
+ iface_customer_facing_display: enabled,
+ iface_customer_facing_display_local: local,
+ iface_customer_facing_display_via_proxy: proxy,
+ } = globalState.config;
+ if (!enabled) {
+ return;
+ }
+
+ let display;
+ if (local) {
+ display = new LocalDisplay(globalState);
+ } else if (proxy) {
+ display = new RemoteDisplay(
+ globalState,
+ pick(deps, ...RemoteDisplay.serviceDependencies)
+ );
+ } else {
+ // FIXME POSREF can this ever happen?
+ return;
+ }
+ // FIXME POSREF this is very inelegant. There are a lot of places that get more complicated
+ // because we want to mount the chrome before the globalState is loaded. Maybe we should
+ // just make the loading screen its own thing that doesn't depend on anything, instead
+ // of having it be a part of the chrome and having to propagate complexity everywhere.
+ env.services.customer_display = display;
+ // Register an effect to update the display automatically when anything it renders changes
+ effect((display) => display.update(), [display]);
+ });
+
+ return service;
+ },
+};
+
+registry.category("services").add("customer_display", customerDisplayService);
diff --git a/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy.js b/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy.js
index 5fc28e93d52..840f5e1c80f 100644
--- a/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy.js
+++ b/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy.js
@@ -23,7 +23,6 @@ export class HardwareProxy extends EventBus {
this.useDebugWeight = false;
this.host = "";
this.keptalive = false;
- this.customerDisplayAvailable = true;
this.connectionInfo = reactive({ status: "disconnected", drivers: {} });
effect(
(info) => {
@@ -191,27 +190,6 @@ export class HardwareProxy extends EventBus {
this.debugWeight = 0;
}
- updateCustomerDisplay(html) {
- if (this.customerDisplayAvailable && this.connectionInfo.status == "connected") {
- return this.message("customer_facing_display", { html }, { timeout: 5000 });
- }
- }
-
- /**
- * @param {string} html
- * @returns {Promise}
- */
- takeControlOfCustomerDisplay(html) {
- return this.message("take_control", { html });
- }
-
- /**
- * @returns {Promise}
- */
- testOwnershipOfCustomerDisplay() {
- return this.host ? this.message("test_ownership") : Promise.reject({ abort: true });
- }
-
// asks the proxy to log some information, as with the debug.log you can provide several arguments.
log() {
return this.message("log", { arguments: [...arguments] });
diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/CustomerFacingDisplayButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/CustomerFacingDisplayButton.js
index 32426144fb3..bc9f998011c 100644
--- a/addons/point_of_sale/static/src/js/ChromeWidgets/CustomerFacingDisplayButton.js
+++ b/addons/point_of_sale/static/src/js/ChromeWidgets/CustomerFacingDisplayButton.js
@@ -2,101 +2,21 @@
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
+import { usePos } from "@point_of_sale/app/pos_hook";
+import { _t } from "@web/core/l10n/translation";
export class CustomerFacingDisplayButton extends Component {
static template = "CustomerFacingDisplayButton";
-
setup() {
- super.setup();
- this.hardwareProxy = useService("hardware_proxy");
- this.local =
- this.env.pos.config.iface_customer_facing_display_local &&
- !this.env.pos.config.iface_customer_facing_display_via_proxy;
- this.state = useState({ status: this.local ? "success" : "failure" });
- this._start();
+ this.pos = usePos();
+ this.customerDisplay = useState(useService("customer_display"));
}
get message() {
return {
success: "",
- warning: this.env._t("Connected, Not Owned"),
- failure: this.env._t("Disconnected"),
- not_found: this.env._t("Customer Screen Unsupported. Please upgrade the IoT Box"),
- }[this.state.status];
- }
- onClick() {
- if (this.local) {
- return this.onClickLocal();
- } else {
- return this.onClickProxy();
- }
- }
- async onClickLocal() {
- this.env.pos.customer_display = window.open("", "Customer Display", "height=600,width=900");
- const renderedHtml = await this.env.pos.render_html_for_customer_facing_display();
- var $renderedHtml = $("
").html(renderedHtml);
- $(this.env.pos.customer_display.document.body).html(
- $renderedHtml.find(".pos-customer_facing_display")
- );
- $(this.env.pos.customer_display.document.head).html(
- $renderedHtml.find(".resources").html()
- );
- }
- async onClickProxy() {
- try {
- const renderedHtml = await this.env.pos.render_html_for_customer_facing_display();
- let ownership = await this.hardwareProxy.takeControlOfCustomerDisplay(renderedHtml);
- if (typeof ownership === "string") {
- ownership = JSON.parse(ownership);
- }
- if (ownership.status === "success") {
- this.state.status = "success";
- } else {
- this.state.status = "warning";
- }
- if (!this.hardwareProxy.customerDisplayAvailable) {
- this.hardwareProxy.customerDisplayAvailable = true;
- this._start();
- }
- } catch (error) {
- if (typeof error == "undefined") {
- this.state.status = "failure";
- } else {
- this.state.status = "not_found";
- }
- }
- }
- _start() {
- if (this.local) {
- return;
- }
-
- const loop = async () => {
- if (this.hardwareProxy.customerDisplayAvailable) {
- try {
- let ownership = await this.hardwareProxy.testOwnershipOfCustomerDisplay();
- if (typeof ownership === "string") {
- ownership = JSON.parse(ownership);
- }
- if (ownership.status === "OWNER") {
- this.state.status = "success";
- } else {
- this.state.status = "warning";
- }
- } catch (error) {
- if (error.abort) {
- // Stop the loop
- return;
- }
- if (typeof error == "undefined") {
- this.state.status = "failure";
- } else {
- this.state.status = "not_found";
- this.hardwareProxy.customerDisplayAvailable = false;
- }
- }
- setTimeout(loop, 3000);
- }
- };
- loop();
+ warning: _t("Connected, Not Owned"),
+ failure: _t("Disconnected"),
+ not_found: _t("Customer Screen Unsupported. Please upgrade the IoT Box"),
+ }[this.customerDisplay.status];
}
}
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index fafb60f846b..857e6926caa 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -15,17 +15,41 @@ import { markRaw, reactive } from "@odoo/owl";
import { ConfirmPopup } from "@point_of_sale/js/Popups/ConfirmPopup";
import { escape } from "@web/core/utils/strings";
import { Mutex } from "@web/core/utils/concurrency";
+import { memoize } from "@web/core/utils/functions";
+import { _t } from "@web/core/l10n/translation";
+import { renderToString } from "@web/core/utils/render";
var QWeb = core.qweb;
-var _t = core._t;
var round_di = utils.round_decimals;
var round_pr = utils.round_precision;
const Markup = utils.Markup;
-// Container of the product images fetched during rendering
-// of customer display. There is no need to observe it, thus,
-// we are putting it outside of PosGlobalState.
-const PRODUCT_ID_TO_IMAGE_CACHE = {};
+/**
+ * Gets a product image as a base64 string so that it can be sent to the
+ * customer display, as the display won't be able to fetch it, since the image
+ * controller requires the client to be logged. This function is memoized on the
+ * product id, so that we will only do this once per product.
+ *
+ * @param {number} productId id of the product
+ * @param {string} writeDate the write date of the product, used as a cache
+ * buster in case the product image has been changed
+ * @returns {string} the base64 representation of the product's image
+ */
+const getProductImage = memoize(function getProductImage(productId, writeDate) {
+ return new Promise(function (resolve, reject) {
+ const img = new Image();
+ img.addEventListener("load", () => {
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ canvas.height = img.height;
+ canvas.width = img.width;
+ ctx.drawImage(img, 0, 0);
+ resolve(canvas.toDataURL("image/jpeg"));
+ });
+ img.addEventListener("error", reject);
+ img.src = `/web/image?model=product.product&field=image_128&id=${productId}&unique=${writeDate}`;
+ });
+});
/**
* If optimization is needed, then we should implement this
@@ -919,105 +943,31 @@ export class PosGlobalState extends PosModel {
return this.orders;
}
- _convert_product_img_to_base64(product, url) {
- return new Promise(function (resolve, reject) {
- var img = new Image();
-
- img.onload = function () {
- var canvas = document.createElement("CANVAS");
- var ctx = canvas.getContext("2d");
-
- canvas.height = this.height;
- canvas.width = this.width;
- ctx.drawImage(this, 0, 0);
-
- var dataURL = canvas.toDataURL("image/jpeg");
- canvas = null;
-
- resolve([product, dataURL]);
- };
- img.crossOrigin = "use-credentials";
- img.src = url;
- });
- }
-
- get customer_display() {
- return this.unwatched.customer_display;
- }
-
- set customer_display(value) {
- this.unwatched.customer_display = markRaw(value);
- }
-
- send_current_order_to_customer_facing_display() {
- var self = this;
- if (!this.config.iface_customer_facing_display) {
+ /**
+ * Renders the HTML for the customer display and returns it as a string.
+ *
+ * @returns {string}
+ */
+ async customerDisplayHTML() {
+ const order = this.get_order();
+ if (!order) {
return;
}
- this.render_html_for_customer_facing_display().then((rendered_html) => {
- if (self.env.pos.customer_display) {
- var $renderedHtml = $("
").html(rendered_html);
- $(self.env.pos.customer_display.document.body).html(
- $renderedHtml.find(".pos-customer_facing_display")
- );
- var orderlines = $(self.env.pos.customer_display.document.body).find(
- ".pos_orderlines_list"
- );
- orderlines.scrollTop(orderlines.prop("scrollHeight"));
- } else if (
- this.config.iface_customer_facing_display_via_proxy &&
- this.hardwareProxy.customerDisplayAvailable
- ) {
- this.hardwareProxy.updateCustomerDisplay(rendered_html);
- }
- });
- }
+ const orderLines = order.get_orderlines();
+ const productImages = Object.fromEntries(
+ await Promise.all(
+ orderLines.map(async ({ product }) => [
+ product.id,
+ await getProductImage(product.id, product.writeDate),
+ ])
+ )
+ );
- /**
- * @returns {Promise
}
- */
- render_html_for_customer_facing_display() {
- var self = this;
- var order = this.get_order();
-
- // If we're using an external device like the IoT Box, we
- // cannot get /web/image?model=product.product because the
- // IoT Box is not logged in and thus doesn't have the access
- // rights to access product.product. So instead we'll base64
- // encode it and embed it in the HTML.
- var get_image_promises = [];
-
- if (order) {
- order.get_orderlines().forEach(function (orderline) {
- var product = orderline.product;
- var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&unique=${product.write_date}`;
-
- // only download and convert image if we haven't done it before
- if (!(product.id in PRODUCT_ID_TO_IMAGE_CACHE)) {
- get_image_promises.push(
- self._convert_product_img_to_base64(product, image_url)
- );
- }
- });
- }
-
- return Promise.all(get_image_promises).then(function (productIdImagePairs) {
- for (const [product, image] of productIdImagePairs) {
- PRODUCT_ID_TO_IMAGE_CACHE[product.id] = image;
- }
- // Collect the product images that will be used in rendering the customer display template.
- const productImages = {};
- if (order) {
- for (const line of order.get_orderlines()) {
- productImages[line.product.id] = PRODUCT_ID_TO_IMAGE_CACHE[line.product.id];
- }
- }
- return QWeb.render("CustomerFacingDisplayOrder", {
- pos: self,
- origin: window.location.origin,
- order: order,
- productImages,
- });
+ return renderToString("CustomerFacingDisplayOrder", {
+ pos: this,
+ origin: window.location.origin,
+ order,
+ productImages,
});
}
diff --git a/addons/point_of_sale/static/src/scss/pos.scss b/addons/point_of_sale/static/src/scss/pos.scss
index b3f4b29e399..5b9d70ac8af 100644
--- a/addons/point_of_sale/static/src/scss/pos.scss
+++ b/addons/point_of_sale/static/src/scss/pos.scss
@@ -220,6 +220,7 @@ td {
height: 0px;
}
.pos .subwindow .subwindow-container{
+ height: 100%;
display: table-cell;
position: relative;
}
@@ -1440,6 +1441,7 @@ td {
height:100%;
}
.pos .screen .content-cell{
+ height: 100%;
width:100%;
}
.pos .screen .content-cell .content-container{
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/CustomerFacingDisplayButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/CustomerFacingDisplayButton.xml
index 686c7cbe86e..68d210ce50a 100644
--- a/addons/point_of_sale/static/src/xml/ChromeWidgets/CustomerFacingDisplayButton.xml
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/CustomerFacingDisplayButton.xml
@@ -2,15 +2,15 @@
-
+
-
+
-
+
-
diff --git a/addons/point_of_sale/static/src/xml/CustomerFacingDisplay/CustomerFacingDisplayOrder.xml b/addons/point_of_sale/static/src/xml/CustomerFacingDisplay/CustomerFacingDisplayOrder.xml
index b307b6c7648..518c8f87226 100644
--- a/addons/point_of_sale/static/src/xml/CustomerFacingDisplay/CustomerFacingDisplayOrder.xml
+++ b/addons/point_of_sale/static/src/xml/CustomerFacingDisplay/CustomerFacingDisplayOrder.xml
@@ -1,18 +1,18 @@
-
+
-
+
-
+
@@ -49,7 +49,7 @@
-
+