[ADD] pos_self_order: QR Code Menu for Restaurant
New module that would allow restaurant customers to see the menu on their own devices by scanning a qr code and navigating to the provided link. Task id 3232765 closes odoo/odoo#114022 Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
<field name="pos_is_table_management" attrs="{'readonly': [('pos_has_active_session','=', True)]}"/>
|
||||
<div class="content-group" attrs="{'invisible': [('pos_is_table_management','=',False)]}">
|
||||
<div class="mt16">
|
||||
<label string="Floors" for="pos_floor_ids" class="o_light_label"/>
|
||||
<label string="Floors" for="pos_floor_ids" class="o_light_label me-2"/>
|
||||
<field name="pos_floor_ids" widget="many2many_tags" attrs="{'readonly': [('pos_has_active_session','=', True)]}" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import tests
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': "POS Self Order",
|
||||
|
||||
'summary': """
|
||||
Addon for the POS App that allows customers to view the menu on their smartphone.
|
||||
""",
|
||||
|
||||
'category': 'Sales/Point Of Sale',
|
||||
|
||||
'depends': ['pos_restaurant', 'http_routing'],
|
||||
'auto_install': ['pos_restaurant'],
|
||||
|
||||
'demo': [
|
||||
'demo/pos_restaurant_demo.xml',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/index.xml',
|
||||
'views/qr_code.xml',
|
||||
'views/custom_link_views.xml',
|
||||
'data/custom_link_data.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/point_of_sale_dashboard.xml',
|
||||
],
|
||||
'assets': {
|
||||
'pos_self_order.assets_self_order': [
|
||||
('include', 'web._assets_helpers'),
|
||||
('include', 'web._assets_backend_helpers'),
|
||||
('include', 'web._assets_primary_variables'),
|
||||
'web/static/src/scss/pre_variables.scss',
|
||||
'web/static/lib/bootstrap/scss/_functions.scss',
|
||||
'web/static/lib/bootstrap/scss/_variables.scss',
|
||||
('include', 'web._assets_bootstrap'),
|
||||
'web/static/src/boot.js',
|
||||
'web/static/src/env.js',
|
||||
'web/static/src/session.js',
|
||||
'web/static/src/core/utils/transitions.scss',
|
||||
'web/static/src/core/**/*',
|
||||
'web/static/lib/owl/owl.js',
|
||||
'web/static/lib/owl/odoo_module.js',
|
||||
'web/static/lib/luxon/luxon.js',
|
||||
|
||||
'web/static/src/views/fields/formatters.js',
|
||||
'web/static/src/libs/fontawesome/css/font-awesome.css',
|
||||
'web/static/lib/odoo_ui_icons/*',
|
||||
|
||||
'pos_self_order/static/src/**/*',
|
||||
'point_of_sale/static/src/utils.js',
|
||||
],
|
||||
'pos_self_order.assets_tests': [
|
||||
'web/static/lib/jquery/jquery.js',
|
||||
'web/static/lib/underscore/underscore.js',
|
||||
'web_tour/static/src/tour_pointer/**/*.js',
|
||||
'web_tour/static/src/tour_service/**/*.js',
|
||||
'pos_self_order/static/tests/tours/**/*',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import controllers
|
||||
@@ -0,0 +1,199 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from typing import List, Dict, Optional
|
||||
import werkzeug
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.http_routing.models.ir_http import unslug
|
||||
from odoo.addons.point_of_sale.models.product import ProductProduct
|
||||
from odoo.addons.pos_self_order.models.pos_config import PosConfig
|
||||
|
||||
|
||||
class PosSelfOrderController(http.Controller):
|
||||
"""
|
||||
This is the controller for the POS Self Order App
|
||||
There is one main route that the client will use to access the POS Self Order App: /menu
|
||||
This route will render the LANDING PAGE of the POS Self Order App
|
||||
And it will pass the needed data to the template: the list of products, pos_config_id, table_id, company_name, currency...
|
||||
After that the client will be able to navigate to the /products route w/o aditional requests
|
||||
to the server, using client side routing.
|
||||
"""
|
||||
|
||||
@http.route("/menu", auth="public")
|
||||
def pos_self_order_redirect(self):
|
||||
pos_config_id = self._get_any_pos_config_with_qr_code_menu_sudo().id
|
||||
return request.redirect(f"/menu/{pos_config_id}")
|
||||
|
||||
@http.route(
|
||||
[
|
||||
"/menu/<pos_name>",
|
||||
"/menu/<pos_name>/products",
|
||||
"/menu/<pos_name>/products/<int:product_id>",
|
||||
],
|
||||
auth="public",
|
||||
website=True,
|
||||
sitemap=True
|
||||
)
|
||||
def pos_self_order_start(self, pos_name: str):
|
||||
"""
|
||||
The user gets this route from the QR code that they scan at the table
|
||||
:param pos_name: the name of the pos config: can be the id or the slugified name of the pos config
|
||||
:return: the rendered template
|
||||
"""
|
||||
_, pos_config_id = unslug(pos_name)
|
||||
pos_config_sudo = self._get_pos_config_sudo(pos_config_id)
|
||||
session_info = request.env["ir.http"].get_frontend_session_info()
|
||||
session_info.update({
|
||||
"currencies": request.env["ir.http"].get_currencies(),
|
||||
"pos_self_order": self._get_self_order_config(pos_config_sudo),
|
||||
})
|
||||
response = request.render("pos_self_order.index", {"session_info": session_info})
|
||||
return response
|
||||
|
||||
@http.route(
|
||||
[
|
||||
"/menu/get-image/<int:product_id>",
|
||||
"/menu/get-image/<int:product_id>/<int:image_size>",
|
||||
],
|
||||
methods=["GET"],
|
||||
type="http",
|
||||
auth="public",
|
||||
)
|
||||
def pos_self_order_get_image(self, product_id: int, image_size: Optional[int] = None):
|
||||
"""
|
||||
This is the route that the POS Self Order App uses to GET THE PRODUCT IMAGES
|
||||
:return: the image of the product
|
||||
:rtype: binary
|
||||
""",
|
||||
# The idea here is to not show the image if there is no pos with the self order menu
|
||||
if not self._get_any_pos_config_with_qr_code_menu_sudo():
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
product_sudo = request.env["product.product"].sudo().browse(product_id)
|
||||
if not product_sudo.available_in_pos:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
if image_size == 1920 and product_sudo.image_1920:
|
||||
return (
|
||||
request.env["ir.binary"]
|
||||
._get_image_stream_from(product_sudo, field_name="image_1920")
|
||||
.get_response()
|
||||
)
|
||||
return (
|
||||
request.env["ir.binary"]
|
||||
._get_image_stream_from(product_sudo, field_name="image_128")
|
||||
.get_response()
|
||||
)
|
||||
|
||||
@http.route("/menu/get-bg-image", methods=["GET"], type="http", auth="public")
|
||||
def pos_self_order_get_bg_image(self, pos_config_id: int):
|
||||
"""
|
||||
Gets the background image for this self order
|
||||
:return: the bg image
|
||||
:rtype: binary
|
||||
""",
|
||||
pos_config_sudo = self._get_pos_config_sudo(pos_config_id)
|
||||
if not pos_config_sudo.self_order_image:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
return (
|
||||
request.env["ir.binary"]
|
||||
._get_image_stream_from(pos_config_sudo, field_name="self_order_image")
|
||||
.get_response()
|
||||
)
|
||||
|
||||
def _get_any_pos_config_with_qr_code_menu_sudo(self) -> PosConfig:
|
||||
"""
|
||||
Returns a PosConfig that allows the QR code menu, if there is one,
|
||||
or raises a NotFound otherwise
|
||||
"""
|
||||
pos_config_sudo = (
|
||||
request.env["pos.config"]
|
||||
.sudo()
|
||||
.search([("self_order_view_mode", "=", True)], limit=1)
|
||||
)
|
||||
if not pos_config_sudo:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
return pos_config_sudo
|
||||
|
||||
def _get_pos_config_sudo(self, pos_config_id: int) -> PosConfig:
|
||||
"""
|
||||
Returns the PosConfig if pos_config_id exist and the pos is configured to allow the menu to be viewed online.
|
||||
If not, it raises a NotFound
|
||||
"""
|
||||
pos_config_sudo = request.env["pos.config"].sudo().browse(
|
||||
int(pos_config_id))
|
||||
if not pos_config_sudo or not pos_config_sudo.self_order_view_mode:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
return pos_config_sudo
|
||||
|
||||
def _get_self_order_config(self, pos_config_sudo: PosConfig) -> Dict:
|
||||
"""
|
||||
Returns the necessary information for the POS Self Order App
|
||||
"""
|
||||
return {
|
||||
"pos_config_id": pos_config_sudo.id,
|
||||
"company_name": pos_config_sudo.company_id.name,
|
||||
"currency_id": pos_config_sudo.currency_id.id,
|
||||
"show_prices_with_tax_included": pos_config_sudo.iface_tax_included == "total",
|
||||
"custom_links": self._get_custom_links(pos_config_sudo.id),
|
||||
"attributes_by_ptal_id": request.env["pos.session"].sudo()._get_attributes_by_ptal_id(),
|
||||
"products": self._get_data_from_products(self._get_available_products_sudo(pos_config_sudo.id), pos_config_sudo.id),
|
||||
}
|
||||
|
||||
def _get_custom_links(self, pos_config_id: int) -> List[Dict[str, str]]:
|
||||
"""
|
||||
On the landing page of the app we can have a number of custom links
|
||||
that are defined by the restaurant employee in the backend.
|
||||
This function returns a list of dictionaries with the attributes of each link
|
||||
that is available for the POS with id pos_config_id.
|
||||
"""
|
||||
domain = [
|
||||
"|",
|
||||
("pos_config_ids", "in", [pos_config_id]),
|
||||
("pos_config_ids", "=", False),
|
||||
]
|
||||
return (
|
||||
request.env["pos_self_order.custom_link"]
|
||||
.sudo()
|
||||
.search_read(domain, fields=["name", "url", "style"], order="sequence")
|
||||
)
|
||||
|
||||
def _get_available_products_sudo(self, pos_config_id: int) -> ProductProduct:
|
||||
"""
|
||||
This function returns the products that are available in the POS with id pos_config_id
|
||||
"""
|
||||
domain = [("available_in_pos", "=", True)]
|
||||
pos_config_sudo = self._get_pos_config_sudo(pos_config_id)
|
||||
if (pos_config_sudo.limit_categories and pos_config_sudo.iface_available_categ_ids):
|
||||
domain.append(("pos_categ_id", "in", pos_config_sudo.iface_available_categ_ids.ids))
|
||||
return (
|
||||
request.env["product.product"]
|
||||
.sudo()
|
||||
.search(domain, order="pos_categ_id.sequence asc nulls last")
|
||||
)
|
||||
|
||||
def _get_data_from_products(self, products_sudo: ProductProduct, pos_config_id: int) -> List[Dict[str, str]]:
|
||||
"""
|
||||
This function adds the price info to each product in the list products_sudo
|
||||
and returns the list of products with the necessary info
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"price_info": product.get_product_info_pos(
|
||||
product.lst_price, 1, int(pos_config_id)
|
||||
)["all_prices"],
|
||||
"has_image": bool(product.image_1920),
|
||||
**product.read(
|
||||
[
|
||||
"id",
|
||||
"display_name",
|
||||
"description_sale",
|
||||
"pos_categ_id",
|
||||
"attribute_line_ids",
|
||||
]
|
||||
)[0],
|
||||
}
|
||||
for product in products_sudo
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="default_custom_link" model="pos_self_order.custom_link">
|
||||
<field name="name">View Menu</field>
|
||||
<field name="url">/products</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<record id="pos_restaurant.pos_config_restaurant" model="pos.config">
|
||||
<field name="self_order_view_mode">True</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import res_config_settings
|
||||
from . import pos_config
|
||||
from . import pos_self_order_custom_link
|
||||
from . import ir_http
|
||||
@@ -0,0 +1,12 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
@classmethod
|
||||
def _get_translation_frontend_modules_name(cls):
|
||||
mods = super()._get_translation_frontend_modules_name()
|
||||
return mods + ['pos_self_order']
|
||||
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class PosConfig(models.Model):
|
||||
_inherit = "pos.config"
|
||||
|
||||
self_order_view_mode = fields.Boolean(
|
||||
string="QR Code Menu",
|
||||
help="Allow customers to view the menu on their phones by scanning the QR code on the table",
|
||||
)
|
||||
self_order_image = fields.Image(
|
||||
string="Self Order Image",
|
||||
help="Image to display on the self order screen",
|
||||
max_width=1920, max_height=1080,
|
||||
)
|
||||
self_order_image_name = fields.Char(
|
||||
string="Self Order Image Name",
|
||||
help="Name of the image to display on the self order screen",
|
||||
)
|
||||
|
||||
def preview_self_order_app(self):
|
||||
"""
|
||||
This function is called when the user clicks on the "Preview App" button
|
||||
:return: object representing the action to open the app's url in a new tab
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_url",
|
||||
"url": f"/menu/{self.id}",
|
||||
"target": "new",
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, api
|
||||
|
||||
|
||||
class PosSelfOrderCustomLink(models.Model):
|
||||
_name = "pos_self_order.custom_link"
|
||||
_description = "Custom links that the restaurant can configure to be displayed on the self order screen"
|
||||
name = fields.Char(string="Label", required=True, translate=True)
|
||||
url = fields.Char(string="URL", required=True)
|
||||
pos_config_ids = fields.Many2many(
|
||||
"pos.config",
|
||||
string="Point of Sales",
|
||||
domain="[('self_order_view_mode', '=', True)]",
|
||||
help="Select for which points of sale you want to display this link. Leave empty to display it for all points of sale. You have to select among the points of sale that have the 'QR Code Menu' feature enabled.",
|
||||
)
|
||||
style = fields.Selection(
|
||||
[
|
||||
("primary", "Primary"),
|
||||
("secondary", "Secondary"),
|
||||
("success", "Success"),
|
||||
("warning", "Warning"),
|
||||
("danger", "Danger"),
|
||||
("info", "Info"),
|
||||
("light", "Light"),
|
||||
("dark", "Dark"),
|
||||
],
|
||||
string="Style",
|
||||
default="primary",
|
||||
required=True,
|
||||
)
|
||||
link_html = fields.Html("Preview", compute="_compute_link_html", store=True, readonly=True)
|
||||
sequence = fields.Integer("Sequence", default=1)
|
||||
|
||||
@api.depends('name', 'style')
|
||||
def _compute_link_html(self):
|
||||
for link in self:
|
||||
if link.name:
|
||||
link.link_html = f"<a class=\"btn btn-{link.style} w-100\">{link.name}</a>"
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
from odoo.tools import split_every
|
||||
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = "res.config.settings"
|
||||
|
||||
pos_self_order_view_mode = fields.Boolean(
|
||||
compute="_compute_pos_module_pos_self_order", store=True, readonly=False)
|
||||
pos_self_order_image = fields.Image(
|
||||
compute="_compute_pos_module_pos_self_order", store=True, readonly=False, max_width=1920, max_height=1080)
|
||||
pos_self_order_image_name = fields.Char(
|
||||
compute="_compute_pos_module_pos_self_order", store=True, readonly=False)
|
||||
|
||||
@api.depends("pos_config_id")
|
||||
def _compute_pos_module_pos_self_order(self):
|
||||
for res_config in self:
|
||||
if not res_config.pos_config_id.self_order_view_mode:
|
||||
res_config.update(
|
||||
{
|
||||
"pos_self_order_view_mode": False,
|
||||
"pos_self_order_image": False,
|
||||
"pos_self_order_image_name": False,
|
||||
}
|
||||
)
|
||||
else:
|
||||
res_config.update(
|
||||
{
|
||||
"pos_self_order_view_mode": res_config.pos_config_id.self_order_view_mode,
|
||||
"pos_self_order_image": res_config.pos_config_id.self_order_image,
|
||||
"pos_self_order_image_name": res_config.pos_config_id.self_order_image_name,
|
||||
}
|
||||
)
|
||||
|
||||
def generate_qr_codes_page(self):
|
||||
"""
|
||||
Generate the data needed to print the QR codes page
|
||||
"""
|
||||
no_of_qr_codes_per_page = 16
|
||||
qr_codes_to_print = [
|
||||
{
|
||||
"id": 0,
|
||||
"url": f"{self.get_base_url()}/menu/{self.pos_config_id.id}",
|
||||
}
|
||||
for i in range(no_of_qr_codes_per_page)
|
||||
]
|
||||
data = {
|
||||
"groups_of_tables": list(split_every(4, qr_codes_to_print, list)),
|
||||
}
|
||||
return self.env.ref("pos_self_order.report_self_order_qr_codes_page").report_action([], data=data)
|
||||
|
||||
def preview_self_order_app(self):
|
||||
"""
|
||||
This function calls the preview_self_order_app function of the pos.config model
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.pos_config_id.preview_self_order_app()
|
||||
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_pos_self_order_custom_link_manager,access.pos_self_order.custom_link_manager,model_pos_self_order_custom_link,point_of_sale.group_pos_manager,1,1,1,1
|
||||
access_pos_self_order_custom_link_user,access.pos_self_order.custom_link_user,model_pos_self_order_custom_link,point_of_sale.group_pos_user,1,0,0,0
|
||||
|
@@ -0,0 +1,13 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
import { formatMonetary } from "@web/views/fields/formatters";
|
||||
export class LandingPage extends Component {
|
||||
static template = "pos_self_order.LandingPage";
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
this.formatMonetary = formatMonetary;
|
||||
this.env.state.currentProduct = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.LandingPage" owl="1">
|
||||
<div class="d-flex flex-column h-100 align-items-center p-4 overflow-auto"
|
||||
t-attf-style="
|
||||
background-image: linear-gradient(to bottom, transparent 33%, #000000 99%), url(/menu/get-bg-image?pos_config_id={{selfOrder.pos_config_id}});
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
">
|
||||
<div class="d-flex flex-column flex-grow-1 w-100 justify-content-end mb-4">
|
||||
<t t-foreach="selfOrder.custom_links" t-as="link" t-key="link.id">
|
||||
<a type="button"
|
||||
t-attf-href="{{!link.url.startsWith('/') and link.url}}"
|
||||
t-on-click="(event)=> link.url.startsWith('/') and env.navigate(link.url, event)"
|
||||
t-attf-class="btn btn-lg w-100 w-md-50 w-lg-25 mx-auto my-2 rounded btn-{{link.style}}">
|
||||
<t t-esc="link.name"/>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
<t t-call="pos_self_order.LandingPageFooter"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="pos_self_order.LandingPageFooter" owl="1">
|
||||
<div class="d-flex align-items-center py-2 px-3 text-white-50 small">Powered By
|
||||
<span class="o_pos_self_order_odoo_logo ms-1 mt-n1">
|
||||
<svg height="16" viewBox="0 0 621 196" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Odoo Logo</title>
|
||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="odoo_logo" fill-rule="nonzero">
|
||||
<path d="M546,196 C504.579,196 471,162.421 471,121 C471,79.579 504.579,46 546,46 C587.421,46 621,79.579 621,121 C621,162.421 587.421,196 546,196 Z M546,165 C570.3,165 590,145.3 590,121 C590,96.7 570.3,77 546,77 C521.7,77 502,96.7 502,121 C502,145.3 521.7,165 546,165 Z M389,196 C347.579,196 314,162.421 314,121 C314,79.579 347.579,46 389,46 C430.421,46 464,79.579 464,121 C464,162.421 430.421,196 389,196 Z M389,165 C413.3,165 433,145.3 433,121 C433,96.7 413.3,77 389,77 C364.7,77 345,96.7 345,121 C345,145.3 364.7,165 389,165 Z M307,120 C307,161.935 273.408,196 231.991,196 C190.575,196 157,162.005 157,120.07 C157,78.134 187.5,46 231.991,46 C248.433,46 263.638,49.496 275.998,58.58 L276,15.09 C276,6.756 283.27,0 291.5,0 C299.728,0 307,6.762 307,15.09 L307,120 Z M232,165 C256.3,165 276,145.3 276,121 C276,96.7 256.3,77 232,77 C207.7,77 188,96.7 188,121 C188,145.3 207.7,165 232,165 Z" fill="#FFF"></path>
|
||||
<path d="M75,196 C33.579,196 0,162.421 0,121 C0,79.579 33.579,46 75,46 C116.421,46 150,79.579 150,121 C150,162.421 116.421,196 75,196 Z M75,165 C99.3,165 119,145.3 119,121 C119,96.7 99.3,77 75,77 C50.7,77 31,96.7 31,121 C31,145.3 50.7,165 75,165 Z" fill="#FFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
export class NavBar extends Component {
|
||||
static template = "pos_self_order.NavBar";
|
||||
static props = { previousPage: String };
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.NavBar" owl="1">
|
||||
<nav t-attf-class="o_self_order_navbar d-flex align-items-center p-2 border-bottom" >
|
||||
<t t-if="props.previousPage">
|
||||
<button class="btn btn-link text-dark"
|
||||
t-on-click="() => env.navigate(props.previousPage)">
|
||||
<i class="fa fa-fw fa-chevron-left">
|
||||
</button>
|
||||
</t>
|
||||
<div class="mx-auto overflow-hidden">
|
||||
<h3 class="m-0 text-primary text-truncate"><t t-esc="selfOrder.company_name"/></h3>
|
||||
</div>
|
||||
<span class="me-4" />
|
||||
</nav>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,235 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onMounted, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
import { useAutofocus } from "@web/core/utils/hooks";
|
||||
import { formatMonetary } from "@web/views/fields/formatters";
|
||||
import { NavBar } from "@pos_self_order/NavBar/NavBar";
|
||||
import { fuzzyLookup } from "@web/core/utils/search";
|
||||
export class ProductList extends Component {
|
||||
static template = "pos_self_order.ProductList";
|
||||
static props = [];
|
||||
static components = {
|
||||
NavBar,
|
||||
};
|
||||
setup() {
|
||||
this.privateState = useState({
|
||||
selectedTag: "",
|
||||
searchIsFocused: false,
|
||||
searchInput: "",
|
||||
navbarIsShown: true,
|
||||
scrollingDown: false,
|
||||
scrolling: false,
|
||||
});
|
||||
this.selfOrder = useSelfOrder();
|
||||
this.formatMonetary = formatMonetary;
|
||||
useAutofocus({ refName: "searchInput", mobile: true });
|
||||
this.productsList = useRef("productsList");
|
||||
this.currentProductCard = useRef(`product_${this.env.state.currentProduct}`);
|
||||
// object with references to each tag heading
|
||||
this.productGroup = Object.fromEntries(
|
||||
Array.from(this.selfOrder.tagList).map((tag) => {
|
||||
return [tag, useRef(`productsWithTag_${tag}`)];
|
||||
})
|
||||
);
|
||||
this.tagButtons = Object.fromEntries(
|
||||
Array.from(this.selfOrder.tagList).map((tag) => {
|
||||
return [tag, useRef(`tag_${tag}`)];
|
||||
})
|
||||
);
|
||||
this.tagList = useRef("tagList");
|
||||
this.header = useRef("header");
|
||||
this.productPage = useRef("productPage");
|
||||
|
||||
onMounted(() => {
|
||||
this.headerHeight = this.header.el.offsetHeight;
|
||||
this.navbarHeight = this.header.el.querySelector("nav").offsetHeight;
|
||||
// TODO: add dvh once it is supported
|
||||
this.productsList.el.style.height = `calc(100vh - ${this.navbarHeight}px)`;
|
||||
this.productsList.el.style.paddingBottom = `100px`;
|
||||
|
||||
// if the user is coming from the product page
|
||||
// we scroll back to the product card that he was looking at before
|
||||
if (this.env.state.currentProduct) {
|
||||
this.scrollTo(this.currentProductCard);
|
||||
}
|
||||
});
|
||||
// this useEffect is used to hide the navbar when the user is scrolling down
|
||||
// ( so the list looks like it's pushing the navbar up )
|
||||
useEffect(
|
||||
(scrollingDown, searchIsFocused) => {
|
||||
if (searchIsFocused) {
|
||||
return;
|
||||
}
|
||||
const threshold = 60;
|
||||
let lastScrollY = this.productsList.el?.scrollTop;
|
||||
let ticking = false;
|
||||
|
||||
const updateScrollDir = () => {
|
||||
if (!this.productsList.el) {
|
||||
return;
|
||||
}
|
||||
const scrollY = this.productsList.el?.scrollTop;
|
||||
const amountScrolled = Math.abs(scrollY - lastScrollY);
|
||||
if (amountScrolled < threshold) {
|
||||
ticking = false;
|
||||
return;
|
||||
}
|
||||
scrollingDown = scrollY > lastScrollY;
|
||||
if (scrollingDown === this.privateState.navbarIsShown) {
|
||||
this.toggleNavbar(this.privateState.navbarIsShown);
|
||||
}
|
||||
lastScrollY = scrollY > 0 ? scrollY : 0;
|
||||
ticking = false;
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!ticking) {
|
||||
// this makes the scrolling smooth
|
||||
window.requestAnimationFrame(updateScrollDir);
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
this.productsList.el.addEventListener("scroll", onScroll);
|
||||
return () => this.productsList.el.removeEventListener("scroll", onScroll);
|
||||
},
|
||||
() => [this.privateState.scrollingDown, this.privateState.searchIsFocused]
|
||||
);
|
||||
// this IntersectionObserver is used to highlight the tag (in the header)
|
||||
// of the category that is currently visible in the viewport
|
||||
useEffect(
|
||||
(searchIsFocused) => {
|
||||
if (searchIsFocused) {
|
||||
return;
|
||||
}
|
||||
const OBSERVING_WINDOW_HEIGHT = 5;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries.filter((entry) => entry.isIntersecting)?.[0];
|
||||
if (entry) {
|
||||
this.privateState.selectedTag =
|
||||
entry.target.querySelector("h3").textContent;
|
||||
// we scroll the tag list horizontally so that the selected tag is in the middle of the screen
|
||||
this.tagList?.el?.scroll({
|
||||
top: 0,
|
||||
left:
|
||||
this.tagButtons[this.privateState.selectedTag].el.offsetLeft -
|
||||
window.innerWidth / 2,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
root: this.productsList.el,
|
||||
rootMargin: `0px 0px -${
|
||||
this.productsList.el.offsetHeight -
|
||||
parseInt(this.productsList.el.style.paddingBottom) -
|
||||
OBSERVING_WINDOW_HEIGHT
|
||||
}px 0px`,
|
||||
}
|
||||
);
|
||||
Object.keys(this.productGroup).forEach((tag) => {
|
||||
observer.observe(this.productGroup[tag]?.el);
|
||||
});
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
},
|
||||
() => [this.privateState.searchIsFocused]
|
||||
);
|
||||
}
|
||||
/**
|
||||
* This function hides or shows the navbar by sliding the whole productPage up or down
|
||||
* @param {boolean} hide - true if the navbar should be hidden, false otherwise
|
||||
*/
|
||||
toggleNavbar(hide) {
|
||||
const animationSteps = hide
|
||||
? [{ top: "0px" }, { top: `-${this.navbarHeight}px` }]
|
||||
: [{ top: `-${this.navbarHeight}px` }, { top: "0px" }];
|
||||
this.productPage.el.animate(animationSteps, {
|
||||
duration: 200,
|
||||
fill: "forwards",
|
||||
});
|
||||
this.privateState.navbarIsShown = !this.privateState.navbarIsShown;
|
||||
}
|
||||
|
||||
scrollToTop() {
|
||||
this.productsList?.el?.scroll({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
/**
|
||||
* This function scrolls the productsList to the ref passed as argument,
|
||||
* it takes into account the height of the header
|
||||
* @param {Object} ref - the ref to scroll to
|
||||
*/
|
||||
scrollTo(ref) {
|
||||
const y = ref?.el.offsetTop;
|
||||
// the intersection observer will detect on which product category we are and
|
||||
// it is possible that after scrolling we are a couple of pixels short of the desired category
|
||||
// so it actually sees the previous category. To avoid this we add a small correction
|
||||
const SCROLL_CORRECTION = 4;
|
||||
const scrollOffset = this.headerHeight - SCROLL_CORRECTION;
|
||||
this.productsList?.el?.scroll({
|
||||
top: y - scrollOffset,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
/**
|
||||
* This function returns the list of products that should be displayed;
|
||||
* it filters the products based on the search input
|
||||
* @returns {Object} the list of products that should be displayed
|
||||
*/
|
||||
filteredProducts() {
|
||||
if (!this.privateState.searchInput) {
|
||||
return this.selfOrder.products;
|
||||
}
|
||||
return fuzzyLookup(
|
||||
this.privateState.searchInput,
|
||||
this.selfOrder.products,
|
||||
(product) => product.name + product.description_sale
|
||||
);
|
||||
}
|
||||
/**
|
||||
* This function is called when a tag is clicked; it selects the chosen tag and deselects all the other tags
|
||||
* @param {string} tag_name
|
||||
*/
|
||||
selectTag(tag_name) {
|
||||
if (this.privateState.selectedTag === tag_name) {
|
||||
this.privateState.selectedTag = "";
|
||||
this.scrollToTop();
|
||||
return;
|
||||
}
|
||||
// When the user clicks on a tag, we scroll to the part of the page
|
||||
// where the products with that tag are displayed.
|
||||
// after the scrolling is done, the intersection observer will
|
||||
// automatically set the privateState.selectedTag to the tag_name
|
||||
this.scrollTo(this.productGroup[tag_name]);
|
||||
}
|
||||
/**
|
||||
* This function is called when the search button is clicked.
|
||||
* It sets the state so the search input is focused.
|
||||
* It also deselects all the selected tags
|
||||
*/
|
||||
focusSearch() {
|
||||
this.privateState.searchIsFocused = true;
|
||||
if (this.privateState.navbarIsShown) {
|
||||
this.toggleNavbar(true);
|
||||
}
|
||||
this.scrollToTop();
|
||||
}
|
||||
/**
|
||||
* This function is called when the search input 'x' button is clicked
|
||||
*/
|
||||
closeSearch() {
|
||||
this.privateState.searchIsFocused = false;
|
||||
this.privateState.searchInput = "";
|
||||
}
|
||||
clickOnProduct(product) {
|
||||
if (product.has_image || product.description_sale || product.attributes.length) {
|
||||
this.env.navigate("/products/" + product.product_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="pos_self_order.ProductList" owl="1">
|
||||
<div t-ref="productPage" class="position-relative">
|
||||
<header t-ref="header" class="bg-white shadow-sm">
|
||||
<NavBar previousPage="'/'"/>
|
||||
<div class="o_self_order_searchbar_filter d-flex justify-content-start align-items-center p-0 m-0 border-bottom">
|
||||
<t t-if="privateState.searchIsFocused" t-call="pos_self_order.SearchBox"/>
|
||||
<t t-if="!privateState.searchIsFocused" t-call="pos_self_order.TagPicker" />
|
||||
<t t-if="!privateState.searchIsFocused" t-call="pos_self_order.SearchIcon"/>
|
||||
</div>
|
||||
</header>
|
||||
<!-- the body is set to overflow: hidden and this div is set to overflow-auto
|
||||
this way we only scroll on the product list, and not on the whole page, which includes the navbar -->
|
||||
<main t-ref="productsList" class="bg-white overflow-auto">
|
||||
<div class="d-flex flex-row flex-wrap">
|
||||
<t t-if="this.privateState.searchIsFocused" t-call="pos_self_order.SearchResults"/>
|
||||
<t t-else="" t-call="pos_self_order.ProductListGroupedByTag"/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.NoProductsMessage" owl="1">
|
||||
<p class="mx-auto mt-3 text-center">
|
||||
No products found
|
||||
</p>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.ProductListGroupedByTag" owl="1">
|
||||
<t t-set="tags" t-value="Object.keys(selfOrder.productListGroupedByTag)" />
|
||||
<t t-if="!tags?.length" t-call="pos_self_order.NoProductsMessage" />
|
||||
<t t-foreach="tags" t-as="tag" t-key="tag">
|
||||
<section t-if="selfOrder.productListGroupedByTag[tag].length" t-ref="productsWithTag_{{tag}}" class="w-100 d-flex flex-wrap">
|
||||
<h3 class="w-100 m-0 mt-n1 ms-n1 pb-3 pt-4 px-3 bg-light" t-esc="tag" />
|
||||
<t t-foreach="selfOrder.productListGroupedByTag[tag]" t-as="product" t-key="product.product_id">
|
||||
<t t-call="pos_self_order.ProductCard"/>
|
||||
</t>
|
||||
</section>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.SearchResults" owl="1">
|
||||
<t t-set="filteredProducts" t-value="filteredProducts()"/>
|
||||
<t t-if="!filteredProducts?.length" t-call="pos_self_order.NoProductsMessage"/>
|
||||
<t t-foreach="filteredProducts" t-as="product" t-key="product.product_id">
|
||||
<t t-call="pos_self_order.ProductCard"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.ProductCard" owl="1">
|
||||
<t t-set="product_image_description" t-value="product.has_image or product.description_sale"/>
|
||||
<div class="o_self_order_item_card d-flex w-100 w-md-50 w-xl-25 p-2 overflow-hidden"
|
||||
t-on-click="() => this.clickOnProduct(product)"
|
||||
t-ref="product_{{product.product_id}}">
|
||||
<div t-attf-class="d-flex {{ product_image_description ? 'flex-column' : 'flex-row align-items-center py-2' }} flex-grow-1 p-2 px-1">
|
||||
<h3 t-attf-class="mb-0 text-900 fw-bold fs-6"
|
||||
t-esc="product.name"
|
||||
/>
|
||||
<p t-attf-class="{{ !product_image_description ? 'ms-auto':'m-0'}} card-text small"
|
||||
t-esc="formatMonetary(product.list_price, {currencyId: selfOrder.currency_id})"
|
||||
/>
|
||||
<p t-if="product.description_sale" class="m-0 text-muted small break-line">
|
||||
<t t-esc="product.description_sale" />
|
||||
</p>
|
||||
</div>
|
||||
<t t-if="product.has_image">
|
||||
<div class="o_self_order_item_card--image position-relative">
|
||||
<img class="position-absolute h-100 w-100 rounded-3 border border-light bg-light"
|
||||
t-attf-src="/menu/get-image/{{ product.product_id }}"
|
||||
alt="Product image"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="pos_self_order.SearchBox" owl="1">
|
||||
<div class="input-group d-flex align-items-center">
|
||||
<input type="text"
|
||||
class="form-control p-3 border-0 shadow-none"
|
||||
t-ref="searchInput"
|
||||
placeholder="Pizza"
|
||||
t-model="privateState.searchInput"/>
|
||||
<div class="px-2">
|
||||
<button class="btn bg-200 rounded" t-on-click="closeSearch">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="pos_self_order.SearchIcon" owl="1">
|
||||
<button class="btn btn-link text-dark px-3" t-on-click="focusSearch">
|
||||
<i class="oi oi-search px-1"></i>
|
||||
</button>
|
||||
</t>
|
||||
<t t-name="pos_self_order.TagPicker" owl="1">
|
||||
<ul t-ref="tagList" class="nav nav-pills flex-nowrap flex-grow-1 px-2 border-end overflow-auto">
|
||||
<t t-foreach="Array.from(selfOrder.tagList)" t-as="tag" t-key="tag">
|
||||
<li class="nav-item mx-1 py-2"
|
||||
t-on-click="() => this.selectTag(tag)">
|
||||
<span
|
||||
t-attf-class="user-select-none nav-link py-2 rounded {{privateState.selectedTag === tag ? 'active' : 'text-dark'}}"
|
||||
t-ref="tag_{{tag}}">
|
||||
<t t-esc="tag"/>
|
||||
</span>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
import { formatMonetary } from "@web/views/fields/formatters";
|
||||
import { NavBar } from "@pos_self_order/NavBar/NavBar";
|
||||
export class ProductMainView extends Component {
|
||||
static template = "pos_self_order.ProductMainView";
|
||||
static props = { product: Object };
|
||||
static components = {
|
||||
NavBar,
|
||||
};
|
||||
setup() {
|
||||
// we want to keep track of the last product that was viewed
|
||||
this.env.state.currentProduct = this.props.product.product_id;
|
||||
this.selfOrder = useSelfOrder();
|
||||
this.formatMonetary = formatMonetary;
|
||||
}
|
||||
}
|
||||
|
||||
export default { ProductMainView };
|
||||
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.ProductMainView" owl="1">
|
||||
<div class="bg-white d-flex flex-column h-100">
|
||||
<NavBar previousPage="'/products'"/>
|
||||
<div class="py-3 overflow-auto">
|
||||
<div class="px-3 border-bottom">
|
||||
<t t-if="props.product.has_image">
|
||||
<img class="w-100 h-100 rounded-3"
|
||||
style="max-height: 400px; object-fit: cover;"
|
||||
t-attf-src="/menu/get-image/{{ props.product.product_id }}/1920"
|
||||
alt="Product image"/>
|
||||
</t>
|
||||
<div class="d-flex flex-column py-3">
|
||||
<h3>
|
||||
<t t-esc="props.product.name"/>
|
||||
</h3>
|
||||
<h4 class="text-primary">
|
||||
<t t-esc="formatMonetary(props.product.list_price, {currencyId: selfOrder.currency_id})"/>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="props.product.description_sale">
|
||||
<div class="px-3 py-3">
|
||||
<t t-esc="props.product.description_sale"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="props.product.attributes">
|
||||
<t t-foreach="props.product.attributes" t-as="attribute" t-key="attribute.id">
|
||||
<div class="attribute_name px-3 py-2 fs-5 fw-bold" t-esc="attribute.name"/>
|
||||
<t t-call="pos_self_order.RadioProductAttribute" t-if="attribute.display_type === 'radio' or attribute.display_type === 'pills'" />
|
||||
<t t-call="pos_self_order.SelectProductAttribute" t-elif="attribute.display_type === 'select'" />
|
||||
<t t-call="pos_self_order.ColorProductAttribute" t-elif="attribute.display_type === 'color'" />
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.RadioProductAttribute" owl="1">
|
||||
<div class="configurator_radio px-3" t-ref="root">
|
||||
<t t-foreach="attribute.values" t-as="value" t-key="value.id">
|
||||
<div class="attribute-name-cell form-check d-flex align-items-center justify-content-between py-2">
|
||||
<input
|
||||
type="radio" class="form-check-input my-1 me-2"
|
||||
t-att-name="attribute.id"
|
||||
t-attf-id="{{ attribute.id }}_{{ value.id }}"
|
||||
t-att-value="value.name"/>
|
||||
<span t-esc="value.name" class="flex-grow-1"/>
|
||||
<label
|
||||
t-att-name="value.name"
|
||||
t-attf-for="{{ attribute.id }}_{{ value.id }}"
|
||||
class="radio_input_value form-check-label text-muted"/>
|
||||
</div>
|
||||
<div t-if="value.price_extra" class="price-extra-cell">
|
||||
<span class="price_extra">
|
||||
+ <t t-esc="formatMonetary(value.price_extra, {currencyId: selfOrder.currency_id})" />
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.SelectProductAttribute" owl="1">
|
||||
<div class="px-3">
|
||||
<select class="configurator_select form-select">
|
||||
<option t-foreach="attribute.values" t-as="value" t-key="value.id" t-att-value="value.name">
|
||||
<t t-esc="value.name"/>
|
||||
<t t-if="value.price_extra">
|
||||
+ <t t-esc="formatMonetary(value.price_extra, {currencyId: selfOrder.currency_id})" />
|
||||
</t>
|
||||
</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.ColorProductAttribute" owl="1">
|
||||
<div>
|
||||
<ul class="color_attribute_list">
|
||||
<li t-foreach="attribute.values" t-as="value" t-key="value.id" class="color_attribute_list_item">
|
||||
<label t-attf-class="configurator_color {{ value.id == state.selected_value ? 'active' : '' }}"
|
||||
t-attf-style="background-color: {{ value.html_color }};" t-att-data-color="value.name">
|
||||
<input type="radio" t-att-value="value.id" t-att-name="attribute.id"/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,92 @@
|
||||
/** @odoo-module */
|
||||
import { Component, whenReady, App, useState, useSubEnv } from "@odoo/owl";
|
||||
import { makeEnv, startServices } from "@web/env";
|
||||
import { setLoadXmlDefaultApp, templates } from "@web/core/assets";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { LandingPage } from "@pos_self_order/LandingPage/LandingPage";
|
||||
import { NavBar } from "@pos_self_order/NavBar/NavBar";
|
||||
import { ProductMainView } from "@pos_self_order/ProductMainView/ProductMainView";
|
||||
import { ProductList } from "@pos_self_order/ProductList/ProductList";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
import { Router } from "@pos_self_order/router";
|
||||
import { groupBy } from "@web/core/utils/arrays";
|
||||
|
||||
class SelfOrderRoot extends Component {
|
||||
static template = "pos_self_order.SelfOrderRoot";
|
||||
static components = {
|
||||
LandingPage,
|
||||
ProductMainView,
|
||||
NavBar,
|
||||
ProductList,
|
||||
Router,
|
||||
};
|
||||
/*
|
||||
This is the Root Component of the SelfOrder App
|
||||
Most of the business logic is done here
|
||||
The app has the folowing screens:
|
||||
0. LandingPage -- the main screen of the app
|
||||
-- it has a button that redirects to the menu
|
||||
1. ProductList -- the screen that shows the list of products ( the menu )
|
||||
2. ProductMainView -- the screen that shows the details of a product ( the product page )
|
||||
*/
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
this.state = useState({
|
||||
currentProduct: 0,
|
||||
});
|
||||
useSubEnv({ state: this.state });
|
||||
this.selfOrder.products = this.selfOrder.products.map(
|
||||
({
|
||||
id,
|
||||
pos_categ_id,
|
||||
price_info,
|
||||
display_name,
|
||||
description_sale,
|
||||
attribute_line_ids,
|
||||
...rest
|
||||
}) => ({
|
||||
product_id: id,
|
||||
list_price: this.selfOrder.show_prices_with_tax_included
|
||||
? price_info["price_with_tax"]
|
||||
: price_info["price_without_tax"],
|
||||
tag: pos_categ_id ? pos_categ_id[1].split(" / ").at(-1) : "Other",
|
||||
// the display name is like '[FURN_7888] Desk Stand with Screen' so we remove the [FURN_7888] part
|
||||
name: display_name.replace(/^\[.*]/g, ""),
|
||||
// we add the attributes to the products
|
||||
attributes: getProductAttributes(
|
||||
attribute_line_ids,
|
||||
this.selfOrder.attributes_by_ptal_id
|
||||
),
|
||||
description_sale: description_sale ? description_sale : "",
|
||||
...rest,
|
||||
})
|
||||
);
|
||||
// we create a set with all the tags that are present in the menu
|
||||
this.selfOrder.tagList = new Set(this.selfOrder.products.map((product) => product.tag));
|
||||
this.selfOrder.productListGroupedByTag = groupBy(this.selfOrder.products, "tag");
|
||||
}
|
||||
}
|
||||
export function getProductAttributes(attribute_line_ids, attributes_by_ptal_id) {
|
||||
return attribute_line_ids.some((id) => id in attributes_by_ptal_id)
|
||||
? attribute_line_ids
|
||||
.map((id) => attributes_by_ptal_id[id])
|
||||
.filter((attr) => attr !== undefined)
|
||||
: [];
|
||||
}
|
||||
|
||||
export async function createPublicRoot() {
|
||||
await whenReady();
|
||||
const env = makeEnv();
|
||||
await startServices(env);
|
||||
const app = new App(SelfOrderRoot, {
|
||||
templates,
|
||||
env: env,
|
||||
dev: env.debug,
|
||||
translateFn: _t,
|
||||
translatableAttributes: ["data-tooltip"],
|
||||
});
|
||||
setLoadXmlDefaultApp(app);
|
||||
return app.mount(document.body);
|
||||
}
|
||||
createPublicRoot();
|
||||
export default { SelfOrderRoot, createPublicRoot };
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.SelfOrderRoot" owl="1">
|
||||
<Router pos_config_id="selfOrder.pos_config_id">
|
||||
<t t-set-slot="default">
|
||||
<LandingPage/>
|
||||
</t>
|
||||
<t t-set-slot="/menu/{string:pos_name}/products">
|
||||
<ProductList/>
|
||||
</t>
|
||||
<t t-set-slot="/menu/{string:pos_name}/products/{int:productId}" t-slot-scope="url">
|
||||
<ProductMainView
|
||||
product="selfOrder.products.find(x => x.product_id === url.productId)"/>
|
||||
</t>
|
||||
</Router>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** @odoo-module */
|
||||
import { useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { session } from "@web/session";
|
||||
|
||||
const selfOrderService = {
|
||||
dependencies: ["rpc"],
|
||||
async start(env, { rpc }) {
|
||||
const object = {
|
||||
...session.pos_self_order,
|
||||
};
|
||||
return object;
|
||||
},
|
||||
};
|
||||
registry.category("services").add("self_order", selfOrderService);
|
||||
|
||||
export function useSelfOrder() {
|
||||
return useState(useService("self_order"));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.break-line {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.o_self_order_item_card--image {
|
||||
width:110px;
|
||||
min-width: 110px;
|
||||
min-height: 110px;
|
||||
|
||||
& img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
.o_self_order_item_card {
|
||||
border-right: 1px solid $border-color;
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
&:nth-child(1n) {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
@include media-breakpoint-up(md) {
|
||||
&:nth-child(2n+1) {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
@include media-breakpoint-up(xl) {
|
||||
&:nth-child(2n+1) {
|
||||
border-right: 1px solid $border-color;
|
||||
}
|
||||
|
||||
&:nth-child(4n+1) {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, useState, useSubEnv, xml } from "@odoo/owl";
|
||||
import { escapeRegExp } from "@web/core/utils/strings";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { zip } from "@web/core/utils/arrays";
|
||||
|
||||
function parseParams(matches, paramSpecs) {
|
||||
return Object.fromEntries(
|
||||
zip(matches, paramSpecs).map(([match, paramSpec]) => {
|
||||
const { type, name } = paramSpec;
|
||||
switch (type) {
|
||||
case "int":
|
||||
return [name, parseInt(match)];
|
||||
case "string":
|
||||
return [name, match];
|
||||
default:
|
||||
throw new Error(`Unknown type ${type}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
export class Router extends Component {
|
||||
static template = xml`<t t-slot="{{state.activeSlot}}" t-props="state.slotProps"/>`;
|
||||
setup() {
|
||||
useSubEnv({ navigate: this.navigate.bind(this) });
|
||||
this.state = useState({
|
||||
activeSlot: "default",
|
||||
slotProps: {},
|
||||
});
|
||||
// this is needed for the back button to work
|
||||
window.addEventListener("popstate", (event) => {
|
||||
this.matchURL();
|
||||
});
|
||||
this.routes = Object.keys(this.props.slots).map((route) => {
|
||||
const paramStrings = route.match(/\{\w+:\w+\}/g);
|
||||
if (!paramStrings) {
|
||||
return { route, paramSpecs: [], regex: new RegExp(`^${route}$`) };
|
||||
}
|
||||
const paramSpecs = paramStrings.map((paramString) => {
|
||||
const [, type, name] = paramString.match(/(\w+):(\w+)/);
|
||||
return { type, name };
|
||||
});
|
||||
const regex = new RegExp(
|
||||
`^${route
|
||||
.split(/\{\w+:\w+\}/)
|
||||
.map((part) => escapeRegExp(part))
|
||||
.join("([^/]+)")}$`
|
||||
);
|
||||
return { route, paramSpecs, regex };
|
||||
});
|
||||
this.matchURL();
|
||||
}
|
||||
|
||||
matchURL() {
|
||||
const path = browser.location.pathname;
|
||||
for (const { route, paramSpecs, regex } of this.routes) {
|
||||
const match = path.match(regex);
|
||||
if (match) {
|
||||
const parsedParams = parseParams(match.slice(1), paramSpecs);
|
||||
this.state.activeSlot = route;
|
||||
this.state.slotProps = parsedParams;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.state.activeSlot = "default";
|
||||
this.state.slotProps = {};
|
||||
}
|
||||
/**
|
||||
* Navigate to the given relative route.
|
||||
* We use the history API to navigate to it.
|
||||
* (this means that we don't make aditional requests to the server)
|
||||
* @param {string} route
|
||||
* @param {Event} event
|
||||
*/
|
||||
navigate(route, event = null) {
|
||||
if (!route.startsWith("/")) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault();
|
||||
const url = new URL(browser.location.href);
|
||||
url.pathname = `menu/${this.props.pos_config_id}${route}`;
|
||||
history.pushState({}, "", url);
|
||||
this.matchURL();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
registry.category("web_tour.tours").add("pos_self_order_tour", {
|
||||
test: true,
|
||||
steps: [
|
||||
{
|
||||
content: "Test that the default View Menu button is present",
|
||||
trigger: ".btn",
|
||||
},
|
||||
{
|
||||
content: "Test that products are present",
|
||||
trigger: ".o_self_order_item_card",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the 'No products found' message is not present",
|
||||
trigger: "body:not(:has(p:contains('No products found')))",
|
||||
isCheck: true,
|
||||
},
|
||||
// We should be on the products screen now
|
||||
{
|
||||
content: "Test that the tag list is present",
|
||||
trigger: ".o_self_order_searchbar_filter li",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the first tag is active",
|
||||
trigger: ".o_self_order_searchbar_filter li:nth-child(1) span.active",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the search icon is present",
|
||||
trigger: ".oi.oi-search",
|
||||
},
|
||||
{
|
||||
content: "Test that the search icon is not present after clicking on it",
|
||||
trigger: "body:not(:has(.oi.oi-search))",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the tag list is not present after clicking on the search icon",
|
||||
trigger: "body:not(:has(.o_search_bar_filter li))",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the search box is visible after clicking on the search icon",
|
||||
trigger: "input[placeholder='Pizza']",
|
||||
run: "text Desk",
|
||||
},
|
||||
{
|
||||
content: "Test that the search results are present",
|
||||
trigger: ".o_self_order_item_card",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the 'No products found' message is not present",
|
||||
trigger: "body:not(:has(p:contains('No products found')))",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
trigger: "input",
|
||||
run: "text xyzxyzxyzxyxyxyxyxyxzxyxyxyxyxyxyxyxyyxzyxzyxzyxyzxyzyx",
|
||||
},
|
||||
{
|
||||
content: "Test that no products are present",
|
||||
trigger: "body:not(:has(.o_self_order_item_card))",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the 'No products found' message is present",
|
||||
trigger: "p:contains('No products found')",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the button to exit the search box is present and click on it",
|
||||
trigger: ".fa.fa-times",
|
||||
},
|
||||
{
|
||||
content: "Test that the search icon is present again",
|
||||
trigger: ".oi.oi-search",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the tag list is present and click on the 2nd tag",
|
||||
trigger: ".o_self_order_searchbar_filter li:nth-child(2)",
|
||||
},
|
||||
{
|
||||
content: "Test that the tag is highlighted and click on it to deactivate it",
|
||||
trigger: ".o_self_order_searchbar_filter li:nth-child(2) span.active",
|
||||
},
|
||||
{
|
||||
content: "Test that the tag is not highlighted anymore",
|
||||
trigger: ".o_self_order_searchbar_filter li:nth-child(2)",
|
||||
isCheck: true,
|
||||
},
|
||||
// After clicking on the 2nd tag, it will be deactivated and the screen should scroll up to the top
|
||||
// when we reach the top of the screen, the intersection observer shoould trigger and select the 1st tag
|
||||
// we check that the 1st tag is now active <-- this would mean that the intersection observer is working properly
|
||||
{
|
||||
content: "Test that the first tag is active",
|
||||
trigger: ".o_self_order_searchbar_filter li:nth-child(1) span.active",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the product description is present and click on it",
|
||||
trigger: "p.text-muted.small.break-line",
|
||||
},
|
||||
// We should now be on the product screen
|
||||
{
|
||||
content: "Test that the product name is present in the product screen",
|
||||
trigger: "div.d-flex.flex-column.py-3 > h3",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the back button is present on the product screen and click on it",
|
||||
trigger: "nav.o_self_order_navbar > button",
|
||||
},
|
||||
// now that we are back on the product list screen, we click the back button again to go to the landing page
|
||||
{
|
||||
content:
|
||||
"Test that the back button is present on the product list screen and click on it",
|
||||
trigger: "nav.o_self_order_navbar > button",
|
||||
},
|
||||
// on the landing page, we look for the View Menu button
|
||||
// finding it also means that the back button works properly
|
||||
{
|
||||
content: "Test that the default View Menu button is present",
|
||||
trigger: ".btn",
|
||||
isCheck: true,
|
||||
},
|
||||
{
|
||||
content: "Test that the back button is not present on the landing page",
|
||||
trigger: "body:not(:has(nav.o_self_order_navbar > button))",
|
||||
isCheck: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_controllers
|
||||
from . import test_frontend
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import odoo
|
||||
from odoo.tests import HttpCase
|
||||
|
||||
@odoo.tests.tagged("post_install", "-at_install")
|
||||
class TestSelfOrderControllers(HttpCase):
|
||||
def test_menu_redirect(self):
|
||||
response = self.url_open("/menu")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.history[0].status_code, 303)
|
||||
chosen_pos_config_id = int(response.url.split("/")[-1])
|
||||
available_pos_config_ids = (
|
||||
self.env["pos.config"].search([("self_order_view_mode", "=", True)]).ids
|
||||
)
|
||||
self.assertTrue(chosen_pos_config_id in available_pos_config_ids)
|
||||
|
||||
self.env["pos.config"].search([]).write({"self_order_view_mode": False})
|
||||
response = self.url_open("/menu")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@@ -0,0 +1,37 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import odoo.tests
|
||||
|
||||
|
||||
@odoo.tests.tagged("post_install", "-at_install")
|
||||
class TestFrontend(odoo.tests.HttpCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.pos_config = self.env["pos.config"].create(
|
||||
{
|
||||
"name": "Bar",
|
||||
"module_pos_restaurant": True,
|
||||
"is_table_management": True,
|
||||
"iface_splitbill": True,
|
||||
"iface_printbill": True,
|
||||
"iface_orderline_notes": True,
|
||||
"start_category": True,
|
||||
"self_order_view_mode": True,
|
||||
}
|
||||
)
|
||||
basic_product = {
|
||||
"name": "Test Product",
|
||||
"type": "product",
|
||||
"available_in_pos": True,
|
||||
"list_price": 10,
|
||||
"taxes_id": False,
|
||||
}
|
||||
self.env["product.product"].create([basic_product for i in range(1000)])
|
||||
|
||||
def test_self_order_tour(self):
|
||||
self.start_tour(
|
||||
f"/menu/{self.pos_config.id}",
|
||||
"pos_self_order_tour",
|
||||
login=None,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
|
||||
<record id="custom_link_action" model="ir.actions.act_window">
|
||||
<field name="name">Custom Links</field>
|
||||
<field name="res_model">pos_self_order.custom_link</field>
|
||||
<field name="view_mode">tree</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="custom_link_tree">
|
||||
<field name="name">custom.link.tree</field>
|
||||
<field name="model">pos_self_order.custom_link</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Custom Links" editable="bottom">
|
||||
<field name="sequence" widget="handle" />
|
||||
<field name="name" placeholder="odoo"/>
|
||||
<field name="url" placeholder="https://odoo.com"/>
|
||||
<field name="pos_config_ids" widget="many2many_tags" placeholder="empty = all points of sale" options="{'no_create': True}"/>
|
||||
<field name="style"/>
|
||||
<field name="link_html"/>
|
||||
</tree>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="pos_self_order.index" name="POS Self Order"><!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Odoo Self Order</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
|
||||
<meta http-equiv="content-type" content="text/html, charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="mobile-web-app-capable" content="yes"/>
|
||||
<script type="text/javascript">
|
||||
var odoo = {
|
||||
csrf_token: "<t t-nocache="The csrf token must always be up to date." t-esc="request.csrf_token(None)"/>",
|
||||
debug: "<t t-esc="debug"/>",
|
||||
__session_info__: <t t-esc="json.dumps(session_info)"/>,
|
||||
};
|
||||
</script>
|
||||
<t t-call-assets="pos_self_order.assets_self_order" />
|
||||
<t t-if="'tests' in debug or test_mode_enabled" t-call-assets="pos_self_order.assets_tests" />
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="pos_self_order_menu_item" model="ir.ui.view">
|
||||
<field name="name">pos.config.kanban.view.inherit.self_order</field>
|
||||
<field name="model">pos.config</field>
|
||||
<field name="inherit_id" ref="point_of_sale.view_pos_config_kanban"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@role='menuitem'][3]" position="after">
|
||||
<field name="self_order_view_mode" invisible="1"/>
|
||||
<div role="menuitem">
|
||||
<a name="preview_self_order_app" type="object" attrs="{'invisible': [('self_order_view_mode','=',False)]}">Mobile Menu</a>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="report_self_order_qr_codes_page" model="ir.actions.report">
|
||||
<field name="name">qr.codes</field>
|
||||
<field name="model">pos.config</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">pos_self_order.qr_codes_page</field>
|
||||
<field name="report_file">pos_self_order.qr_codes_page</field>
|
||||
<field name="print_report_name">"QR codes"</field>
|
||||
<field name="binding_model_id" ref="model_pos_config"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
<record id="paperformat_qrcodes" model="report.paperformat">
|
||||
<field name="name">QR Codes Page</field>
|
||||
<field name="default" eval="True"/>
|
||||
<field name="header_line" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
|
||||
<template id="qr_codes_page">
|
||||
<t t-call="web.basic_layout">
|
||||
<div class="page">
|
||||
<h1 class="mb-5 ms-2">
|
||||
QR Code Menu
|
||||
</h1>
|
||||
<!-- There is a very annoying problem here:
|
||||
wkhtmltopdf does not handle flex-wrap. ( at least not in this version )
|
||||
This makes it so the bootstrap grid system does not work.
|
||||
We have to do a workaround -->
|
||||
<t t-foreach="groups_of_tables" t-as="group_of_tables">
|
||||
<div class="d-flex">
|
||||
<t t-foreach="group_of_tables" t-as="table">
|
||||
<t t-call="pos_self_order.qr_code"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="qr_code">
|
||||
<t t-set="qr_code_size" t-value="180"/>
|
||||
<div class="d-flex flex-column col align-items-center w-25 p-2" >
|
||||
<div class="m-2 position-relative top-0 start-0">
|
||||
<img t-att-src="'/report/barcode/QR/%s?width=%s&height=%s&barLevel=H' %(table['url'], qr_code_size, qr_code_size)"
|
||||
class="position-relative top-0 start-0"
|
||||
/>
|
||||
<t t-call="pos_self_order.first_letter_of_odoo_logo_svg"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template id="first_letter_of_odoo_logo_svg">
|
||||
<svg
|
||||
class="position-absolute top-50 start-50"
|
||||
style="-webkit-transform: translate(-50%, -50%);"
|
||||
t-att-width="qr_code_size/4"
|
||||
t-att-height="qr_code_size/4"
|
||||
viewBox="0 0 120 120" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<rect width="120" height="120" rx="33" fill="white"/>
|
||||
<g>
|
||||
<path d="M60 110C87.6142 110 110 87.6142 110 60C110 32.3858 87.6142 10 60 10C32.3858 10 10 32.3858 10 60C10 87.6142 32.3858 110 60 110Z" fill="#9C5789"/>
|
||||
<path d="M59.5166 89.6961C75.8029 89.6961 89.0055 76.4935 89.0055 60.2072C89.0055 43.9209 75.8029 30.7182 59.5166 30.7182C43.2303 30.7182 30.0276 43.9209 30.0276 60.2072C30.0276 76.4935 43.2303 89.6961 59.5166 89.6961Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form_menu" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.pos_self_order.view</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="pos_restaurant.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<setting id="is_table_management" position="after">
|
||||
<setting string="QR Code Menu" help="Allow customers to see the menu from their phone" id="pos_self_order_view_mode">
|
||||
<field name="pos_self_order_view_mode"/>
|
||||
<div class="content-group d-flex gap-5" attrs="{'invisible': [('pos_self_order_view_mode','=',False)]}">
|
||||
<div class="d-flex flex-column align-items-start w-50">
|
||||
<button name="pos_self_order.custom_link_action" type="action" string="Configure Buttons"/>
|
||||
<button name="generate_qr_codes_page" type="object" string="Generate Qr Codes" class=""/>
|
||||
<button name="preview_self_order_app" type="object" string="Preview" />
|
||||
</div>
|
||||
<div class="d-flex flex-column align-items-start">
|
||||
<div class="d-flex flex-column align-items-start">
|
||||
<field name="pos_self_order_image_name" invisible ="1"/>
|
||||
<label for="pos_self_order_image" string="Set Background Image"/>
|
||||
<field name="pos_self_order_image" class="w-75" filename="pos_self_order_image_name"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</setting>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -34,13 +34,14 @@ import { status, useComponent, useEffect, useRef, onWillUnmount } from "@odoo/ow
|
||||
* @param {Object} [params]
|
||||
* @param {string} [params.refName] override the ref name "autofocus"
|
||||
* @param {boolean} [params.selectAll] if true, will select the entire text value.
|
||||
* @param {boolean} [params.mobile] if true, will autofocus on mobile devices.
|
||||
* @returns {Ref} the element reference
|
||||
*/
|
||||
export function useAutofocus({ refName, selectAll } = {}) {
|
||||
export function useAutofocus({ refName, selectAll, mobile } = {}) {
|
||||
const comp = useComponent();
|
||||
const ref = useRef(refName || "autofocus");
|
||||
// Prevent autofocus in mobile
|
||||
if (comp.env.isSmall) {
|
||||
if (!mobile && comp.env.isSmall) {
|
||||
return ref;
|
||||
}
|
||||
// LEGACY
|
||||
|
||||
@@ -153,6 +153,77 @@ QUnit.module("utils", () => {
|
||||
await mount(MyComponent, target, { env });
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"useAutofocus works when isSmall and you provide mobile param",
|
||||
async function (assert) {
|
||||
class MyComponent extends Component {
|
||||
setup() {
|
||||
this.inputRef = useAutofocus({ mobile: true });
|
||||
}
|
||||
}
|
||||
MyComponent.template = xml`
|
||||
<span>
|
||||
<input type="text" t-ref="autofocus" />
|
||||
</span>
|
||||
`;
|
||||
|
||||
const fakeUIService = {
|
||||
start(env) {
|
||||
const ui = {};
|
||||
Object.defineProperty(env, "isSmall", {
|
||||
get() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return ui;
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("ui", fakeUIService);
|
||||
|
||||
const env = await makeTestEnv();
|
||||
const target = getFixture();
|
||||
const comp = await mount(MyComponent, target, { env });
|
||||
assert.strictEqual(document.activeElement, comp.inputRef.el);
|
||||
}
|
||||
);
|
||||
QUnit.test(
|
||||
"useAutofocus does not focus when isSmall and you don't provide mobile param",
|
||||
async function (assert) {
|
||||
class MyComponent extends Component {
|
||||
setup() {
|
||||
this.inputRef = useAutofocus();
|
||||
}
|
||||
}
|
||||
MyComponent.template = xml`
|
||||
<span>
|
||||
<input type="text" t-ref="autofocus" />
|
||||
</span>
|
||||
`;
|
||||
|
||||
const fakeUIService = {
|
||||
start(env) {
|
||||
const ui = {};
|
||||
Object.defineProperty(env, "isSmall", {
|
||||
get() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return ui;
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("ui", fakeUIService);
|
||||
|
||||
const env = await makeTestEnv();
|
||||
const target = getFixture();
|
||||
const comp = await mount(MyComponent, target, { env });
|
||||
assert.notEqual(document.activeElement, comp.inputRef.el);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("supports different ref names", async (assert) => {
|
||||
class MyComponent extends Component {
|
||||
setup() {
|
||||
|
||||
Reference in New Issue
Block a user