[IMP] pos_self_order: Allow ordering from web app
Currently from the pos_self_order app a customer can only view the menu. This PR adds the option to order items. A customer of the restaurant will now be able to scan a qr code on their table, and navigate to a website where they will be able to order items from the pos. The customer can select product variants and add a specific notes for each of the ordered products. This version does not support online payment. This means that the customer will still have to pay by the usual methods. The pos can be configured such that a customer can add items to an existing order. For example, ordering a second coffee will result in an update of the existing pos order containing the first coffee. Alternatively, the pos can be configured such that each order made from the web app results in a new pos order. Orders made from the self order app will be placed in the same pos session as regular orders. Once a customer orders from the self order app, a new order will appear in the pos app. Thus, a waiter will be able to quickly see all the orders placed by customers from the self order app. He/She will then be able to send the order to the preparation display. This step has to be done manually. In the interest of security, each table now has an associated access token. It is included in url that the customer gets from the qr code. When sending the order, the self order app also sends back to the server this access token. The server will only process the order if it receives a valid access token. When placing an order, the server will respond with the pos_reference and access_token of the created order. The web app will keep this data and will send it back to the server as a proof of ownership in the event that it intends to get the latest state of the order or to update it. The server will only reveal information about an existing order when the request contains a valid pair of pos_reference and access_token. closes odoo/odoo#121029 Task: 3058586 Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
This commit is contained in:
@@ -71,7 +71,7 @@
|
||||
<t t-name="kanban-menu">
|
||||
<div class="container dropdown-pos-config">
|
||||
<div class="row">
|
||||
<div class="col-6 o_kanban_card_manage_section o_kanban_manage_view">
|
||||
<div class="col-6 o_kanban_card_manage_section o_kanban_manage_view w-100">
|
||||
<div role="menuitem" class="o_kanban_card_manage_title">
|
||||
<span>View</span>
|
||||
</div>
|
||||
@@ -103,7 +103,7 @@
|
||||
<div>
|
||||
<div class="o_kanban_card_header">
|
||||
<div class="o_kanban_card_header_title mb16">
|
||||
<div class="o_primary">
|
||||
<div class="o_primary o_kanban_card_header_title_name">
|
||||
<t t-esc="record.name.value"/>
|
||||
</div>
|
||||
<t t-if="!record.current_session_id.raw_value && record.pos_session_username.value">
|
||||
|
||||
@@ -1,60 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': "POS Self Order",
|
||||
|
||||
'summary': """
|
||||
"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',
|
||||
"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',
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"views/index.xml",
|
||||
"views/qr_code.xml",
|
||||
"views/custom_link_views.xml",
|
||||
"data/custom_link_data.xml",
|
||||
"data/restaurant_table_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',
|
||||
"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"),
|
||||
("include", "web._assets_bootstrap_backend"),
|
||||
"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/**/*',
|
||||
"pos_self_order.assets_tests": [
|
||||
"web/static/lib/jquery/jquery.js",
|
||||
"web/static/lib/underscore/underscore.js",
|
||||
"web_tour/static/src/tour_pointer/**/*.xml",
|
||||
"web_tour/static/src/tour_pointer/**/*.js",
|
||||
"web_tour/static/src/tour_service/**/*",
|
||||
"pos_self_order/static/tests/tours/**/*",
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
"license": "LGPL-3",
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import controllers
|
||||
from . import qr_menu
|
||||
from . import ordering
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
# -*- 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,464 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from typing import List, Dict, Optional, Union
|
||||
import werkzeug
|
||||
import uuid
|
||||
|
||||
from odoo import http, fields
|
||||
from odoo.http import request
|
||||
from odoo.tools import partition
|
||||
|
||||
|
||||
from odoo.addons.pos_self_order.controllers.utils import (
|
||||
get_pos_config_sudo,
|
||||
get_table_sudo,
|
||||
)
|
||||
from odoo.addons.pos_self_order.models.product_product import ProductProduct
|
||||
from odoo.addons.pos_self_order.models.pos_order import PosOrder, PosOrderLine
|
||||
from odoo.addons.pos_self_order.models.pos_config import PosConfig
|
||||
|
||||
|
||||
class PosSelfOrderController(http.Controller):
|
||||
@http.route(
|
||||
"/pos-self-order/send-order",
|
||||
auth="public",
|
||||
type="json",
|
||||
website=True,
|
||||
)
|
||||
def pos_self_order_send_order(
|
||||
self,
|
||||
cart: List[Dict],
|
||||
pos_config_id: int,
|
||||
table_access_token: Optional[str] = None,
|
||||
order_pos_reference: Optional[str] = None,
|
||||
order_access_token: Optional[str] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
There are 2 types of self order configurations:
|
||||
1. Order at table and pay after each order
|
||||
==> pos doesn't allow ongoing orders
|
||||
2. Order at table and pay at the end of the meal
|
||||
==> pos allows ongoing orders
|
||||
(order at table means order from personal device (smartphone))
|
||||
In case 1. we will create a new order each time the user sends an order
|
||||
In case 2. we will create a new order only if the user does not have an ongoing order
|
||||
(we will look whether there is an order with state = 'draft'
|
||||
with the same pos_reference and order_access_token as the ones that the user has provided)
|
||||
:param cart: order lines
|
||||
:param pos_config_id: the id of the POS where the order is being sent
|
||||
:param table_access_token: the access token of the table where the order is being sent (UUID v4)
|
||||
:param pos_reference: the id of the order that is being edited; ex: "Order 00001-001-0001"
|
||||
:param order_access_token: the access token of the order that is being edited (UUID v4)
|
||||
:return: dictionary with keys: pos_reference, order_access_token
|
||||
"""
|
||||
# we will only respond with the order "access_token" and "id". After clicking on the `Order` button,
|
||||
# the customer will be redirected to the landing page.
|
||||
# From the `landing page`, the customer can go to the `My Orders` page.
|
||||
# on this page, the app will automatically make a request to the `view-order` route,
|
||||
# so the customer will get the order details that way
|
||||
|
||||
pos_config_sudo = get_pos_config_sudo(pos_config_id)
|
||||
|
||||
if not pos_config_sudo.self_order_table_mode or not pos_config_sudo.has_active_session:
|
||||
raise werkzeug.exceptions.BadRequest()
|
||||
|
||||
order = self._form_order(
|
||||
self._create_order_data(
|
||||
cart,
|
||||
pos_config_sudo,
|
||||
table_access_token,
|
||||
order_pos_reference,
|
||||
order_access_token,
|
||||
)
|
||||
)
|
||||
posted_order_id = (
|
||||
request.env["pos.order"]
|
||||
.sudo()
|
||||
.create_from_ui(
|
||||
[order],
|
||||
draft=True,
|
||||
)[0]
|
||||
.get("id")
|
||||
)
|
||||
|
||||
return (
|
||||
request.env["pos.order"]
|
||||
.sudo()
|
||||
.browse(posted_order_id)
|
||||
.read(["pos_reference", "access_token"])[0]
|
||||
)
|
||||
|
||||
# # is_trusted is set to True by default.
|
||||
# # We need to set it to False, because we are creating an order from a public route
|
||||
# # FIXME: make it so we only set it to false if it is a new order
|
||||
# # if the server already acknowledged the order, we should not set it to false again
|
||||
# # every time the user adds a new item to the order
|
||||
# request.env["pos.order"].sudo().browse(order_resp.get("id")).is_trusted = False
|
||||
|
||||
@http.route("/pos-self-order/view-order", auth="public", type="json", website=True)
|
||||
def pos_self_order_view_order(self, pos_reference: str, access_token: str) -> Dict:
|
||||
"""
|
||||
Return some information about a given order.
|
||||
This is used by the frontend to find the latest state of an order.
|
||||
(e.g. if a customer orders something from the self order app and then the waiter adds an item to the order,
|
||||
the customer will be able to see the new item in the order)
|
||||
:param pos_reference: the id of the order that we want to view
|
||||
:param access_token: the access token of the order that we want to view -- this is needed
|
||||
for security reasons, so that only the customer who created the order
|
||||
can view it
|
||||
"""
|
||||
order_sudo = self._find_order(pos_reference, access_token)
|
||||
if not order_sudo:
|
||||
raise werkzeug.exceptions.Unauthorized()
|
||||
return order_sudo._export_for_self_order()
|
||||
|
||||
def _create_order_data(
|
||||
self,
|
||||
cart: List[Dict],
|
||||
pos_config_sudo: PosConfig,
|
||||
table_access_token: Optional[str],
|
||||
order_pos_reference: Optional[str],
|
||||
order_access_token: Optional[str],
|
||||
) -> Dict[str, Union[int, str, List[Dict]]]:
|
||||
"""
|
||||
Given the data, we decide whether we need to create a new order or update an existing one
|
||||
:return: dictionary with the data needed to create a new order
|
||||
"""
|
||||
|
||||
# We only care about the previous order if the customer is paying after the meal
|
||||
# ( paying after the meal means that the customer can order multiple times on the same order )
|
||||
order_sudo = pos_config_sudo.self_order_pay_after == "meal" and self._find_order(
|
||||
order_pos_reference, order_access_token, state="draft"
|
||||
)
|
||||
|
||||
return {
|
||||
**(
|
||||
order_sudo._get_self_order_data()
|
||||
if order_sudo
|
||||
else self._create_new_order_data(pos_config_sudo, table_access_token)
|
||||
),
|
||||
"lines": self._create_orderlines(
|
||||
self._merge_orderlines(
|
||||
cart, order_sudo.lines.export_for_ui(), order_sudo.config_id
|
||||
)
|
||||
if order_sudo
|
||||
else cart,
|
||||
pos_config_sudo,
|
||||
),
|
||||
}
|
||||
|
||||
def _create_new_order_data(
|
||||
self, pos_config_sudo: PosConfig, table_access_token: str
|
||||
) -> Dict[str, Union[str, int]]:
|
||||
"""
|
||||
:param pos_config_id: the id of the pos config for which we want to create the order
|
||||
:param table_access_token: the access token of the table for which we want to create the order
|
||||
:return: a dictionary containing the data that we need to create a new order
|
||||
"""
|
||||
table_sudo = get_table_sudo(table_access_token)
|
||||
pos_session_sudo = pos_config_sudo.current_session_id
|
||||
if not table_sudo or not pos_session_sudo:
|
||||
raise werkzeug.exceptions.Unauthorized()
|
||||
|
||||
sequence_number = self._get_sequence_number(table_sudo.id, pos_session_sudo.id)
|
||||
|
||||
return {
|
||||
"id": self._generate_unique_id(pos_session_sudo.id, table_sudo.id, sequence_number),
|
||||
"sequence_number": sequence_number,
|
||||
"access_token": uuid.uuid4().hex,
|
||||
"session_id": pos_session_sudo.id,
|
||||
"table_id": table_sudo.id,
|
||||
}
|
||||
|
||||
def _merge_orderlines(
|
||||
self,
|
||||
incoming_orderlines: List[Dict],
|
||||
existing_orderlines: List[Dict],
|
||||
pos_config_sudo: PosConfig,
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
If the customer has an existing order, we will add the items from the existing order to the current cart.
|
||||
(This is because the create_from_ui method will overwrite the old items from the order)
|
||||
:param cart: The cart from the frontend.
|
||||
:return: list of dictionaries with the items from the cart and the items from the existing order
|
||||
"""
|
||||
has_merge_candidate = lambda item: self._has_merge_candidate(item, existing_orderlines)
|
||||
|
||||
orderlines_to_be_merged, orderlines_to_be_appended = partition(
|
||||
has_merge_candidate, incoming_orderlines
|
||||
)
|
||||
|
||||
return (
|
||||
self._get_updated_orderlines(
|
||||
existing_orderlines, orderlines_to_be_merged, pos_config_sudo
|
||||
)
|
||||
+ orderlines_to_be_appended
|
||||
)
|
||||
|
||||
def _has_merge_candidate(self, item: Dict, existing_orderlines: List[Dict]) -> bool:
|
||||
return any(self._can_be_merged(item, line) for line in existing_orderlines)
|
||||
|
||||
def _can_be_merged(self, orderline1, orderline2):
|
||||
return self._is_pos_groupable(orderline1["product_id"]) and all(
|
||||
orderline1.get(key, "") == orderline2.get(key, "")
|
||||
for key in PosOrderLine._get_unique_keys()
|
||||
)
|
||||
|
||||
def _get_updated_orderlines(
|
||||
self, orderlines: List[Dict], cart: List[Dict], pos_config_sudo: PosConfig
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
:returns updated version of orderlines that takes into account the new items from the cart
|
||||
"""
|
||||
return [
|
||||
self._get_updated_orderline(line, updated_item, pos_config_sudo)
|
||||
if (
|
||||
updated_item := next(
|
||||
(item for item in cart if self._can_be_merged(item, line)), None
|
||||
)
|
||||
)
|
||||
else line
|
||||
for line in orderlines
|
||||
]
|
||||
|
||||
def _get_updated_orderline(
|
||||
self, line: Dict, updated_item: Dict, pos_config_sudo: PosConfig
|
||||
) -> Dict:
|
||||
"""
|
||||
This function assumes that the price of the product was not changed between the time the
|
||||
first items were added to the order and the time the new items were added to the order.
|
||||
"""
|
||||
new_qty = updated_item["qty"] + line["qty"]
|
||||
new_price = (
|
||||
request.env["product.product"]
|
||||
.sudo()
|
||||
.browse(int(line["product_id"]))
|
||||
._get_price_info(pos_config_sudo, qty=new_qty)
|
||||
)
|
||||
return {
|
||||
**line,
|
||||
**{
|
||||
"price_subtotal": new_price["price_without_tax"],
|
||||
"price_subtotal_incl": new_price["price_with_tax"],
|
||||
"qty": new_qty,
|
||||
"customer_note": updated_item.get("customer_note") or line.get("customer_note"),
|
||||
},
|
||||
}
|
||||
|
||||
def _is_pos_groupable(self, product_id: int) -> bool:
|
||||
return (
|
||||
request.env["product.product"].sudo().browse(int(product_id)).uom_id.is_pos_groupable
|
||||
)
|
||||
|
||||
def _get_full_product_name(self, name: str, description: str) -> str:
|
||||
"""
|
||||
:param name: ex: "[E-COM12] Desk Organizer"
|
||||
:param description: ex: "M, Leather"
|
||||
:return: ex: "Desk Organizer (M, Leather)"
|
||||
"""
|
||||
return f"{name} ({description})" if description else name
|
||||
|
||||
def _create_orderlines(self, cart: List[Dict], pos_config_sudo: PosConfig) -> List[Dict]:
|
||||
"""
|
||||
Function that constructs the order lines (as the create_from_ui method expects them) from the cart.
|
||||
From the frontend we only get basic data of the cart, such as the id of the product and the quantity.
|
||||
We need to get the other details of the product from the database.
|
||||
This is done for security reasons.
|
||||
"""
|
||||
|
||||
return [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
self._create_orderline(
|
||||
item,
|
||||
pos_config_sudo,
|
||||
),
|
||||
]
|
||||
# having the "full product name" means that the orderline is already in the db
|
||||
# so we don't need to create a new object
|
||||
if not item.get("full_product_name") else [0, 0, item]
|
||||
for item in cart
|
||||
]
|
||||
|
||||
def _create_orderline(self, item: Dict, pos_config_sudo: PosConfig) -> Dict:
|
||||
"""
|
||||
Function that constructs an order line (as the create_from_ui method expects it) from an item from the cart.
|
||||
"""
|
||||
product_sudo = request.env["product.product"].sudo().browse(int(item.get("product_id")))
|
||||
return {
|
||||
"product_id": item.get("product_id"),
|
||||
"qty": item.get("qty"),
|
||||
**self._get_orderline_price_info(product_sudo, item, pos_config_sudo),
|
||||
"discount": 0,
|
||||
"tax_ids": product_sudo.taxes_id,
|
||||
"pack_lot_ids": [],
|
||||
"description": item.get("description"), # ex "M, Leather"
|
||||
"full_product_name": self._get_full_product_name( # ex: "Desk Organizer (M, Leather)"
|
||||
product_sudo._get_name(),
|
||||
item.get("description"),
|
||||
),
|
||||
"customer_note": item.get("customer_note"),
|
||||
"price_manually_set": False,
|
||||
"note": "",
|
||||
"uuid": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
def _get_orderline_price_info(
|
||||
self, product_sudo: ProductProduct, item: Dict, pos_config_sudo: PosConfig
|
||||
) -> Dict:
|
||||
price_extra = (
|
||||
self._compute_price_extra(product_sudo, item.get("description"), pos_config_sudo)
|
||||
if item.get("description")
|
||||
else 0
|
||||
)
|
||||
price_unit = product_sudo.lst_price + price_extra
|
||||
price_subtotal_info = product_sudo._get_price_info(
|
||||
pos_config_sudo, price_unit, item.get("qty")
|
||||
)
|
||||
|
||||
return {
|
||||
"price_unit": price_unit,
|
||||
"price_subtotal": price_subtotal_info["price_without_tax"],
|
||||
"price_subtotal_incl": price_subtotal_info["price_with_tax"],
|
||||
"price_extra": price_extra,
|
||||
}
|
||||
|
||||
def _compute_price_extra(
|
||||
self, product_sudo: ProductProduct, description: str, pos_config_sudo: PosConfig
|
||||
) -> float:
|
||||
"""
|
||||
Function that computes the price_extra of a product based on the description.
|
||||
:param description: The description of the product. ex: "M, Leather"
|
||||
represents the selected values of the product's attributes.
|
||||
"""
|
||||
|
||||
return sum(
|
||||
v["price_extra"]["price_without_tax"]
|
||||
for attr, selected_value_name in zip(
|
||||
product_sudo._get_attributes(pos_config_sudo), description.split(", ")
|
||||
)
|
||||
for v in attr["values"]
|
||||
if v["name"] == selected_value_name
|
||||
)
|
||||
|
||||
def _form_order(self, order: Dict) -> Dict:
|
||||
"""
|
||||
Function that constructs an order object (as the create_from_ui method expects it)
|
||||
It takes as input the order object that was created by self._create_order_data()
|
||||
:param order: Dictionary representing an order.
|
||||
:return: Dictionary representing an order (as the create_from_ui method expects it).
|
||||
"""
|
||||
# The self order can only update an order that was created on the self order itself.
|
||||
# This is because the self order does not have the ability to update orders that were created on the POS,
|
||||
# because the app wouldn't know the access token of the order.
|
||||
|
||||
return {
|
||||
"id": order.get("id"),
|
||||
"data": {
|
||||
"name": order.get("id"),
|
||||
"amount_paid": 0,
|
||||
"amount_total": self._compute_amount_total(order.get("lines")),
|
||||
"amount_tax": self._compute_amount_tax(order.get("lines")),
|
||||
"amount_return": 0,
|
||||
"lines": order.get("lines"),
|
||||
"statement_ids": [],
|
||||
"pos_session_id": order.get("session_id"),
|
||||
"partner_id": False,
|
||||
"user_id": request.session.uid,
|
||||
# we only need the digits of the pos_reference, not the whole id, which starts with word "Order"
|
||||
"uid": order.get("id")[6:],
|
||||
"sequence_number": order.get("sequence_number"),
|
||||
"creation_date": str(fields.Datetime.now()),
|
||||
"fiscal_position_id": False,
|
||||
"to_invoice": False,
|
||||
"to_ship": False,
|
||||
"is_tipped": False,
|
||||
"tip_amount": 0,
|
||||
"access_token": order.get("access_token"),
|
||||
"server_id": False,
|
||||
"table_id": order.get("table_id"),
|
||||
},
|
||||
"to_invoice": False,
|
||||
"session_id": order.get("session_id"),
|
||||
}
|
||||
|
||||
def _compute_amount_total(self, lines: List[Dict]) -> float:
|
||||
return sum(orderline[2].get("price_subtotal_incl") for orderline in lines)
|
||||
|
||||
def _compute_amount_tax(self, lines: List[Dict]) -> float:
|
||||
return sum(
|
||||
orderline[2].get("price_subtotal_incl") - orderline[2].get("price_subtotal")
|
||||
for orderline in lines
|
||||
)
|
||||
|
||||
# the 2nd argument of pos_reference is the login number;
|
||||
# the regular pos receives the login number from the backend on the first load
|
||||
# the problem is that if the pos has login number 1, for example
|
||||
# and we create a new order in the database, with sequence number 1, for example,
|
||||
# when the regular pos will try to create a new order, it will also use sequence number 1
|
||||
# and login number 1 and the new order will have the same id as the order created by the self order app
|
||||
# so it will ultimately not be recorded in the database.
|
||||
# ( the regular pos does not check what was the last order id from the database )
|
||||
# it keeps track itself of the last order id -- this is why this problem occurs
|
||||
# I thought that it is best to make the pos_self_order app work around
|
||||
# the problem, instead of modifying the regular pos.
|
||||
# so I decided to use the numbers from 900 to 999 for the login number
|
||||
# this way, the regular pos will have a login number of 1, 2, 3, etc
|
||||
# and the pos_self_order app will have a login number of 901, 902, 903, etc,
|
||||
# where 901 is the login number of the table with id = 1,
|
||||
# 902 is the login number of the table with id = 2, etc
|
||||
# TODO: make it so the regular pos can't have a login number of 900 or more
|
||||
# FIXME: what happens if `table_id` is larger that 100?
|
||||
# [possible fix: using a different pos session for the self order an synchronizing the orders]
|
||||
def _generate_unique_id(self, pos_session_id: int, table_id: int, sequence_number: int) -> str:
|
||||
"""
|
||||
Generates a public identification number for the order.
|
||||
:return: The order name. Example: Order 00001-001-0001
|
||||
"""
|
||||
return f"Order {pos_session_id:0>5}-{900 + table_id :0>3}-{sequence_number:0>4}"
|
||||
|
||||
def _find_order(
|
||||
self,
|
||||
pos_reference: Optional[str],
|
||||
order_access_token: Optional[str],
|
||||
state: Optional[str] = None,
|
||||
) -> PosOrder:
|
||||
"""
|
||||
:return: pos order with the given pos_reference, access token and state
|
||||
"""
|
||||
return (
|
||||
pos_reference
|
||||
and order_access_token
|
||||
and request.env["pos.order"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
("pos_reference", "=", pos_reference),
|
||||
("access_token", "=", order_access_token),
|
||||
*(state and [("state", "=", state)] or []),
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
)
|
||||
|
||||
def _get_sequence_number(self, table_id: int, session_id: int) -> int:
|
||||
"""
|
||||
:return: the new sequence number for the order.
|
||||
"""
|
||||
return (
|
||||
request.env["pos.order"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
(
|
||||
"pos_reference",
|
||||
"=like",
|
||||
f"Order {session_id:0>5}-{900 + table_id}-____",
|
||||
),
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
.sequence_number
|
||||
+ 1
|
||||
) or 1
|
||||
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from typing import Dict, Optional
|
||||
import werkzeug
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.pos_self_order.controllers.utils import (
|
||||
get_pos_config_sudo,
|
||||
get_any_pos_config_sudo,
|
||||
get_table_sudo,
|
||||
)
|
||||
from odoo.addons.pos_self_order.models.pos_config import PosConfig
|
||||
from odoo.addons.pos_self_order.models.pos_restaurant import RestaurantTable
|
||||
|
||||
|
||||
class PosQRMenuController(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 additional requests
|
||||
to the server, using client side routing.
|
||||
"""
|
||||
|
||||
@http.route("/menu", auth="public")
|
||||
def pos_self_order_redirect(self):
|
||||
return request.redirect(f"/menu/{get_any_pos_config_sudo().id}")
|
||||
|
||||
@http.route(
|
||||
[
|
||||
"/menu/<pos_name>",
|
||||
"/menu/<pos_name>/products",
|
||||
"/menu/<pos_name>/products/<int:product_id>",
|
||||
"/menu/<pos_name>/cart",
|
||||
"/menu/<pos_name>/orders",
|
||||
],
|
||||
auth="public",
|
||||
website=True,
|
||||
sitemap=True,
|
||||
)
|
||||
def pos_self_order_start(
|
||||
self, pos_name: str, at: Optional[str] = None, product_id: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
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. (e.g. "3" or "bar-3")
|
||||
:param at: the access token of the table; we call this argument "at" because
|
||||
it will be displayed in the url ( as a query param ), and "at" is more user friendly than "table_access_token"
|
||||
the user is allowed to order only if this "at" matches the access token of a table
|
||||
:param product_id: the id of the product that the user wants to see the details of;
|
||||
we never actually use this argument in this function ( it will be read by the client side router ),
|
||||
but we still have it here, because otherwise we get a Warning in the logs
|
||||
:return: the rendered template
|
||||
"""
|
||||
return request.render(
|
||||
"pos_self_order.index",
|
||||
{
|
||||
"session_info": {
|
||||
**request.env["ir.http"].get_frontend_session_info(),
|
||||
"currencies": request.env["ir.http"].get_currencies(),
|
||||
"pos_self_order_data": self._get_self_order_config(
|
||||
get_pos_config_sudo(pos_name),
|
||||
get_table_sudo(table_access_token=at),
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@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] = 128):
|
||||
"""
|
||||
This is the route that the POS Self Order App uses to GET THE PRODUCT IMAGES.
|
||||
:return: the image of the product (or a default one in case no product is found)
|
||||
or an exception if there is no pos configured as self order.
|
||||
:rtype: binary
|
||||
"""
|
||||
if not get_any_pos_config_sudo():
|
||||
raise werkzeug.exceptions.Unauthorized()
|
||||
|
||||
return (
|
||||
request.env["ir.binary"]
|
||||
._get_image_stream_from(
|
||||
request.env["product.product"].sudo().browse(product_id),
|
||||
field_name=f"image_{image_size}",
|
||||
)
|
||||
.get_response()
|
||||
)
|
||||
|
||||
@http.route(
|
||||
"/menu/get-bg-image/<int:pos_config_id>", 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 = 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_self_order_config(
|
||||
self, pos_config_sudo: PosConfig, table_sudo: RestaurantTable
|
||||
) -> Dict:
|
||||
"""
|
||||
Returns the necessary information for the POS Self Order App
|
||||
"""
|
||||
return {
|
||||
**pos_config_sudo._get_self_order_data(),
|
||||
**(
|
||||
table_sudo
|
||||
and pos_config_sudo.has_active_session
|
||||
and pos_config_sudo.self_order_table_mode
|
||||
# having the table_id in the frontend means that the client will be able to order
|
||||
and {"table": table_sudo._get_self_order_data()}
|
||||
or {}
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
import werkzeug
|
||||
from typing import Optional
|
||||
|
||||
from odoo.http import request
|
||||
from odoo.addons.http_routing.models.ir_http import unslug
|
||||
from odoo.addons.pos_self_order.models.pos_config import PosConfig
|
||||
from odoo.addons.pos_restaurant.models.pos_restaurant import RestaurantTable
|
||||
|
||||
|
||||
def get_pos_config_sudo(pos_config_name: str) -> 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
|
||||
:param pos_config_name: The name of the pos config. Can be the id or the slug. ex: 3 or Bar-3
|
||||
"""
|
||||
return (
|
||||
request.env["pos.config"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
("id", "=", unslug(str(pos_config_name))[1]),
|
||||
("self_order_view_mode", "=", True),
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
) or _raise(werkzeug.exceptions.NotFound())
|
||||
|
||||
|
||||
def get_any_pos_config_sudo() -> PosConfig:
|
||||
"""
|
||||
Returns a PosConfig that allows the QR code menu, if there is one,
|
||||
or raises a NotFound otherwise
|
||||
"""
|
||||
return (
|
||||
request.env["pos.config"].sudo().search([("self_order_view_mode", "=", True)], limit=1)
|
||||
) or _raise(werkzeug.exceptions.NotFound())
|
||||
|
||||
|
||||
def get_table_sudo(table_access_token: Optional[str]) -> Optional[RestaurantTable]:
|
||||
return table_access_token and (
|
||||
request.env["restaurant.table"]
|
||||
.sudo()
|
||||
.search([("access_token", "=", table_access_token), ("active", "=", True)], limit=1)
|
||||
)
|
||||
|
||||
|
||||
def _raise(e):
|
||||
raise e
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<function model="restaurant.table" name="_update_access_token" />
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,6 +1,17 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<record id="pos_restaurant.pos_config_restaurant" model="pos.config">
|
||||
<field name="self_order_view_mode">True</field>
|
||||
</record>
|
||||
<data noupdate="1">
|
||||
<record id="pos_restaurant.pos_config_restaurant" model="pos.config">
|
||||
<field name="self_order_view_mode">True</field>
|
||||
<field name="self_order_table_mode">True</field>
|
||||
<field name="self_order_image"
|
||||
model="pos.config"
|
||||
eval="obj().env.ref('pos_restaurant.pos_config_restaurant')._self_order_default_image()"
|
||||
/>
|
||||
<field name="self_order_image_name"
|
||||
model="pos.config"
|
||||
eval="obj().env.ref('pos_restaurant.pos_config_restaurant')._self_order_default_image_name()"
|
||||
/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import res_config_settings
|
||||
from . import pos_config
|
||||
from . import pos_self_order_custom_link
|
||||
from . import ir_http
|
||||
from . import pos_config
|
||||
from . import pos_order
|
||||
from . import pos_restaurant
|
||||
from . import pos_self_order_custom_link
|
||||
from . import product_product
|
||||
from . import res_config_settings
|
||||
|
||||
@@ -4,9 +4,9 @@ from odoo import models
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
_inherit = "ir.http"
|
||||
|
||||
@classmethod
|
||||
def _get_translation_frontend_modules_name(cls):
|
||||
mods = super()._get_translation_frontend_modules_name()
|
||||
return mods + ['pos_self_order']
|
||||
return mods + ["pos_self_order"]
|
||||
|
||||
@@ -1,26 +1,104 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from typing import Optional, List, Dict, Callable
|
||||
from werkzeug.urls import url_quote
|
||||
import base64
|
||||
|
||||
|
||||
from odoo import api, fields, models, modules
|
||||
from odoo.tools import file_open, split_every
|
||||
|
||||
from odoo.addons.pos_self_order.models.product_product import ProductProduct
|
||||
from odoo.addons.pos_self_order.models.pos_order import PosOrderLine
|
||||
|
||||
|
||||
class PosConfig(models.Model):
|
||||
_inherit = "pos.config"
|
||||
|
||||
def _self_order_default_image_name(self) -> str:
|
||||
return "default_background.jpg"
|
||||
|
||||
def _self_order_default_image(self) -> bytes:
|
||||
image_path = modules.get_module_resource(
|
||||
"pos_self_order", "static/img", self._self_order_default_image_name()
|
||||
)
|
||||
return base64.b64encode(file_open(image_path, "rb").read())
|
||||
|
||||
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_table_mode = fields.Boolean(
|
||||
string="Self Order",
|
||||
help="Allow customers to Order from their phones",
|
||||
)
|
||||
self_order_pay_after = fields.Selection(
|
||||
[("each", "Each Order"), ("meal", "Meal")],
|
||||
string="Pay After:",
|
||||
default="each",
|
||||
help="Choose when the customer will pay",
|
||||
required=True,
|
||||
)
|
||||
self_order_image = fields.Image(
|
||||
string="Self Order Image",
|
||||
help="Image to display on the self order screen",
|
||||
max_width=1920, max_height=1080,
|
||||
max_width=1920,
|
||||
max_height=1080,
|
||||
default=_self_order_default_image,
|
||||
)
|
||||
self_order_image_name = fields.Char(
|
||||
string="Self Order Image Name",
|
||||
help="Name of the image to display on the self order screen",
|
||||
default=_self_order_default_image_name,
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""
|
||||
We want self ordering to be enabled by default
|
||||
(This would have been nicer to do using a default value
|
||||
directly on the fields, but `module_pos_restaurant` would not be
|
||||
known at the time that the function for this default value would run)
|
||||
"""
|
||||
for vals in vals_list:
|
||||
if vals.get("module_pos_restaurant"):
|
||||
vals.update(
|
||||
{
|
||||
"self_order_view_mode": True,
|
||||
"self_order_table_mode": True,
|
||||
}
|
||||
)
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.depends("module_pos_restaurant")
|
||||
def _compute_self_order(self):
|
||||
"""
|
||||
Self ordering will only be enabled for restaurants
|
||||
"""
|
||||
for record in self:
|
||||
if not record.module_pos_restaurant:
|
||||
record.self_order_view_mode = False
|
||||
record.self_order_table_mode = False
|
||||
|
||||
def _get_self_order_route(self, table_id: Optional[int] = None) -> str:
|
||||
self.ensure_one()
|
||||
base_route = f"/menu/{self.id}"
|
||||
if not self.self_order_table_mode:
|
||||
return base_route
|
||||
table_access_token = (
|
||||
self.env["restaurant.table"]
|
||||
.search(
|
||||
[("active", "=", True), *(table_id and [("id", "=", table_id)] or [])], limit=1
|
||||
)
|
||||
.access_token
|
||||
)
|
||||
return f"{base_route}?at={table_access_token}"
|
||||
|
||||
def _get_self_order_url(self, table_id: Optional[int] = None) -> str:
|
||||
self.ensure_one()
|
||||
return url_quote(self.get_base_url() + self._get_self_order_route(table_id))
|
||||
|
||||
def preview_self_order_app(self):
|
||||
"""
|
||||
This function is called when the user clicks on the "Preview App" button
|
||||
@@ -29,6 +107,126 @@ class PosConfig(models.Model):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_url",
|
||||
"url": f"/menu/{self.id}",
|
||||
"url": self._get_self_order_route(),
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
def _get_self_order_custom_links(self) -> 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.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return (
|
||||
self.env["pos_self_order.custom_link"]
|
||||
.sudo()
|
||||
.search_read(
|
||||
[
|
||||
"|",
|
||||
("pos_config_ids", "in", [self.id]),
|
||||
("pos_config_ids", "=", False),
|
||||
],
|
||||
fields=["name", "url", "style"],
|
||||
order="sequence",
|
||||
)
|
||||
)
|
||||
|
||||
def _get_available_products(self) -> ProductProduct:
|
||||
"""
|
||||
This function returns the products that are available in the given PosConfig
|
||||
"""
|
||||
self.ensure_one()
|
||||
return (
|
||||
self.env["product.product"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
("available_in_pos", "=", True),
|
||||
*(
|
||||
self.limit_categories
|
||||
and self.iface_available_categ_ids
|
||||
and [("pos_categ_id", "in", self.iface_available_categ_ids.ids)]
|
||||
or []
|
||||
),
|
||||
],
|
||||
order="pos_categ_id.sequence asc nulls last",
|
||||
)
|
||||
)
|
||||
|
||||
def _get_self_order_data(self) -> Dict:
|
||||
self.ensure_one()
|
||||
return {
|
||||
"pos_config_id": self.id,
|
||||
"company_name": self.company_id.name,
|
||||
"currency_id": self.currency_id.id,
|
||||
"show_prices_with_tax_included": self.iface_tax_included == "total",
|
||||
"custom_links": self._get_self_order_custom_links(),
|
||||
"products": self._get_available_products()._get_self_order_data(self),
|
||||
"has_active_session": self.has_active_session,
|
||||
"orderline_unique_keys": PosOrderLine._get_unique_keys(),
|
||||
}
|
||||
|
||||
def _generate_data_for_qr_codes_page(self, cols: int = 4) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
:cols: the number of qr codes per row
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
"floors": self._split_qr_codes_list(
|
||||
self._get_qr_codes_info(cols * cols),
|
||||
cols,
|
||||
)
|
||||
}
|
||||
|
||||
def _get_qr_codes_info(self, total_number: int) -> List[Dict]:
|
||||
"""
|
||||
total_number: the number of qr codes to generate (in the case where we don't have
|
||||
floor management)
|
||||
return: a list of dictionaries with the following keys:
|
||||
- name: the name of the floor
|
||||
- tables: a list of dictionaries with the following keys:
|
||||
- id: the id of the table
|
||||
- url: the url of the table
|
||||
- name?: the name of the table
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.self_order_table_mode:
|
||||
return self.floor_ids._get_data_for_qr_codes_page(self._get_self_order_url)
|
||||
else:
|
||||
return self._get_default_qr_codes(total_number, self._get_self_order_url)
|
||||
|
||||
def _split_qr_codes_list(self, floors: List[Dict], cols: int) -> List[Dict]:
|
||||
"""
|
||||
:floors: the list of floors
|
||||
:cols: the number of qr codes per row
|
||||
"""
|
||||
self.ensure_one()
|
||||
return [
|
||||
{
|
||||
"name": floor.get("name"),
|
||||
"rows_of_tables": list(split_every(cols, floor["tables"], list)),
|
||||
}
|
||||
for floor in floors
|
||||
]
|
||||
|
||||
def _get_default_qr_codes(
|
||||
self, number: int, url: Callable[[Optional[int]], str]
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
:number: the number of qr codes to generate
|
||||
:url: a function that takes a table id and returns the url of the table
|
||||
"""
|
||||
self.ensure_one()
|
||||
return [
|
||||
{
|
||||
"tables": [
|
||||
{
|
||||
"id": 0,
|
||||
"url": url(),
|
||||
}
|
||||
]
|
||||
* number,
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import re
|
||||
from typing import Dict, Union
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class PosOrderLine(models.Model):
|
||||
_inherit = "pos.order.line"
|
||||
|
||||
def _export_for_ui(self, orderline):
|
||||
return {
|
||||
"uuid": orderline.uuid,
|
||||
"note": orderline.note,
|
||||
**super()._export_for_ui(orderline),
|
||||
}
|
||||
|
||||
def _get_description(self):
|
||||
"""
|
||||
The pos sends a "description" key to the backend, which is a string containing the selected value for
|
||||
each attribute of the product, separated by a comma. In the db we end up storing the
|
||||
"full_product_name", which is composed of the product name and the description.
|
||||
:line.full_product_name: ex: "Desk Organizer (M, Leather)"
|
||||
:return: ex: "M, Leather"
|
||||
"""
|
||||
self.ensure_one()
|
||||
description = re.findall(r"\(([^)]+)\)", self.full_product_name)
|
||||
if description:
|
||||
# It might happen that the product has a name with parenthesis. ex: "Salad (Vegie)"
|
||||
# In that case the full_product_name will be "Salad (Vegie) (Small)", but are interested in returning the variant "Small"
|
||||
# That's why we return the last element of the list
|
||||
return description[-1]
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _get_unique_keys():
|
||||
"""
|
||||
These are the keys that define the uniqueness of an orderline.
|
||||
We use them to check if 2 orderlines can be merged or not.
|
||||
"""
|
||||
return ["product_id", "description", "customer_note"]
|
||||
|
||||
|
||||
class PosOrder(models.Model):
|
||||
_inherit = "pos.order"
|
||||
|
||||
def _export_for_self_order(self) -> Dict:
|
||||
"""
|
||||
Given an order, it returns a dictionary with the keys that we need in the frontend
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
"pos_reference": self.pos_reference,
|
||||
"access_token": self.access_token,
|
||||
"state": self.state,
|
||||
"date": str(self.date_order),
|
||||
"amount_total": self.amount_total,
|
||||
"amount_tax": self.amount_tax,
|
||||
"items": [
|
||||
{
|
||||
"product_id": line.product_id.id,
|
||||
"qty": line.qty,
|
||||
"customer_note": line.customer_note,
|
||||
"price_extra": line.product_id._get_price_info(
|
||||
self.config_id,
|
||||
line.price_extra,
|
||||
),
|
||||
"description": line._get_description(),
|
||||
}
|
||||
for line in self.lines
|
||||
],
|
||||
}
|
||||
|
||||
def _get_self_order_data(self) -> Dict[str, Union[str, int]]:
|
||||
return {
|
||||
"id": self.pos_reference,
|
||||
"sequence_number": self.sequence_number,
|
||||
"access_token": self.access_token,
|
||||
"session_id": self.session_id.id,
|
||||
"table_id": self.table_id.id,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import uuid
|
||||
from typing import Dict, Callable, List, Optional
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class RestaurantFloor(models.Model):
|
||||
_inherit = "restaurant.floor"
|
||||
|
||||
def _get_data_for_qr_codes_page(self, url: Callable):
|
||||
return [
|
||||
{
|
||||
"name": floor.name,
|
||||
"tables": floor.table_ids.filtered("active")._get_data_for_qr_codes_page(url),
|
||||
}
|
||||
for floor in self
|
||||
]
|
||||
|
||||
|
||||
class RestaurantTable(models.Model):
|
||||
_inherit = "restaurant.table"
|
||||
|
||||
access_token = fields.Char(
|
||||
"Security Token",
|
||||
copy=False,
|
||||
required=True,
|
||||
readonly=True,
|
||||
default=lambda self: self._get_access_token(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_access_token():
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
def _get_self_order_data(self) -> Dict:
|
||||
self.ensure_one()
|
||||
return self.read(["name", "access_token"])[0]
|
||||
|
||||
def _get_data_for_qr_codes_page(self, url: Callable[[Optional[int]], str]) -> List[Dict]:
|
||||
return [
|
||||
{
|
||||
"id": table.id,
|
||||
"name": table.name,
|
||||
"url": url(table.id),
|
||||
}
|
||||
for table in self
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _update_access_token(self):
|
||||
"""
|
||||
We define a new access token field in this file.
|
||||
There might already be databases that have restaurant.table records.
|
||||
They will now also get an access token each; the problem is that
|
||||
because of the way `default` values work, all those tables that
|
||||
exist in the db will get the same access token.
|
||||
This method will be ran at the moment the pos_self_order module
|
||||
is installed and will thus make sure that every record has a
|
||||
different access token.
|
||||
"""
|
||||
tables = self.env["restaurant.table"].search([])
|
||||
for table in tables:
|
||||
table.access_token = self._get_access_token()
|
||||
@@ -1,17 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
from odoo import fields, models, api
|
||||
from markupsafe import escape
|
||||
|
||||
|
||||
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"
|
||||
_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",
|
||||
string="Points of Sale",
|
||||
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.",
|
||||
)
|
||||
@@ -33,8 +37,8 @@ class PosSelfOrderCustomLink(models.Model):
|
||||
link_html = fields.Html("Preview", compute="_compute_link_html", store=True, readonly=True)
|
||||
sequence = fields.Integer("Sequence", default=1)
|
||||
|
||||
@api.depends('name', 'style')
|
||||
@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>"
|
||||
link.link_html = f'<a class="btn btn-{link.style} w-100">{escape(link.name)}</a>'
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from odoo import models
|
||||
|
||||
from odoo.addons.point_of_sale.models.pos_config import PosConfig
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = "product.product"
|
||||
|
||||
def _get_name(self) -> str:
|
||||
"""
|
||||
Returns the name of the product without the code.
|
||||
ex: product_sudo.display_name is '[FURN_7888] Desk Stand with Screen (Red)'
|
||||
:return: 'Desk Stand with Screen (Red)' (we remove the [FURN_7888] part)
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.with_context(display_default_code=False).display_name
|
||||
|
||||
def _filter_applicable_attributes(self, attributes_by_ptal_id: Dict) -> List[Dict]:
|
||||
"""
|
||||
The attributes_by_ptal_id is a dictionary that contains all the attributes that have
|
||||
[('create_variant', '=', 'no_variant')]
|
||||
This method filters out the attributes that are not applicable to the product in self
|
||||
"""
|
||||
self.ensure_one()
|
||||
return [
|
||||
attributes_by_ptal_id[id]
|
||||
for id in self.attribute_line_ids.ids
|
||||
if attributes_by_ptal_id.get(id) is not None
|
||||
]
|
||||
|
||||
def _get_attributes(self, pos_config_sudo: PosConfig) -> List[Dict]:
|
||||
self.ensure_one()
|
||||
|
||||
attributes = self._filter_applicable_attributes(
|
||||
self.env["pos.session"].sudo()._get_attributes_by_ptal_id()
|
||||
)
|
||||
return self._add_price_info_to_attributes(
|
||||
attributes,
|
||||
pos_config_sudo,
|
||||
)
|
||||
|
||||
def _add_price_info_to_attributes(
|
||||
self, attributes: List[Dict], pos_config_sudo: PosConfig
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Here we replace the price_extra of each attribute value with a price_extra
|
||||
dictionary that includes the price with taxes included and the price with taxes excluded
|
||||
"""
|
||||
self.ensure_one()
|
||||
for attribute in attributes:
|
||||
for value in attribute["values"]:
|
||||
value.update(
|
||||
{
|
||||
"price_extra": self._get_price_info(
|
||||
pos_config_sudo, value.get("price_extra")
|
||||
)
|
||||
}
|
||||
)
|
||||
return attributes
|
||||
|
||||
def _get_price_info(
|
||||
self, pos_config: PosConfig, price: Optional[float] = None, qty: int = 1
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Function that returns a dict with the price info of a given product
|
||||
"""
|
||||
self.ensure_one()
|
||||
# if price == None it means that a price was not passed as a parameter, so we use the product's list price
|
||||
# it could happen that a price was passed, but it was 0; in that case we want to use this 0 as the argument,
|
||||
# and not the product's list price
|
||||
if price is None:
|
||||
price = pos_config.pricelist_id._get_product_price(
|
||||
self, qty, currency=pos_config.currency_id
|
||||
)
|
||||
price_info = pos_config.default_fiscal_position_id.map_tax(self.taxes_id).compute_all(
|
||||
price, pos_config.currency_id, qty, product=self
|
||||
)
|
||||
|
||||
return {
|
||||
"list_price": price_info["total_included"]
|
||||
if pos_config.iface_tax_included == "total"
|
||||
else price_info["total_excluded"],
|
||||
"price_without_tax": price_info["total_excluded"],
|
||||
"price_with_tax": price_info["total_included"],
|
||||
}
|
||||
|
||||
def _get_self_order_data(self, pos_config: PosConfig) -> List[Dict]:
|
||||
"""
|
||||
returns the list of products with the necessary info for the self order app
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"price_info": product._get_price_info(pos_config),
|
||||
"has_image": bool(product.image_1920),
|
||||
"attributes": product._get_attributes(pos_config),
|
||||
"name": product._get_name(),
|
||||
"product_id": product.id,
|
||||
"description_sale": product.description_sale,
|
||||
"tag": product.pos_categ_id.name if product.pos_categ_id else "Other",
|
||||
"is_pos_groupable": product.uom_id.is_pos_groupable,
|
||||
}
|
||||
for product in self
|
||||
]
|
||||
@@ -1,19 +1,35 @@
|
||||
# -*- 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)
|
||||
compute="_compute_pos_module_pos_self_order", store=True, readonly=False
|
||||
)
|
||||
pos_self_order_table_mode = fields.Boolean(
|
||||
compute="_compute_pos_module_pos_self_order", store=True, readonly=False
|
||||
)
|
||||
pos_self_order_pay_after = fields.Selection(
|
||||
[("each", "Each Order"), ("meal", "Meal")],
|
||||
string="Pay After:",
|
||||
default="each",
|
||||
help="Choose when the customer will pay",
|
||||
readonly=False,
|
||||
required=True,
|
||||
)
|
||||
pos_self_order_image = fields.Image(
|
||||
compute="_compute_pos_module_pos_self_order", store=True, readonly=False, max_width=1920, max_height=1080)
|
||||
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)
|
||||
compute="_compute_pos_module_pos_self_order", store=True, readonly=False
|
||||
)
|
||||
|
||||
@api.depends("pos_config_id")
|
||||
def _compute_pos_module_pos_self_order(self):
|
||||
@@ -22,6 +38,8 @@ class ResConfigSettings(models.TransientModel):
|
||||
res_config.update(
|
||||
{
|
||||
"pos_self_order_view_mode": False,
|
||||
"pos_self_order_table_mode": False,
|
||||
"pos_self_order_pay_after": "each",
|
||||
"pos_self_order_image": False,
|
||||
"pos_self_order_image_name": False,
|
||||
}
|
||||
@@ -30,31 +48,32 @@ class ResConfigSettings(models.TransientModel):
|
||||
res_config.update(
|
||||
{
|
||||
"pos_self_order_view_mode": res_config.pos_config_id.self_order_view_mode,
|
||||
"pos_self_order_table_mode": res_config.pos_config_id.self_order_table_mode,
|
||||
"pos_self_order_pay_after": res_config.pos_config_id.self_order_pay_after,
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
# self_order_table_mode is only available if self_order_view_mode is True
|
||||
@api.onchange("pos_self_order_view_mode")
|
||||
def _onchange_pos_self_order_view_mode(self):
|
||||
if not self.pos_self_order_view_mode:
|
||||
self.pos_self_order_table_mode = False
|
||||
|
||||
@api.onchange("pos_self_order_table_mode")
|
||||
def _onchange_pos_self_order_table_mode(self):
|
||||
if self.pos_self_order_table_mode:
|
||||
self.pos_self_order_view_mode = True
|
||||
|
||||
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)
|
||||
return self.env.ref("pos_self_order.report_self_order_qr_codes_page").report_action(
|
||||
[], data=self.pos_config_id._generate_data_for_qr_codes_page(cols=3)
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,8 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class IncrementCounter extends Component {
|
||||
static template = "pos_self_order.IncrementCounter";
|
||||
static props = ["value", "onClick"];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.o_self_order_incr_button button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.IncrementCounter" owl="1">
|
||||
<div class="o_self_order_incr_button btn-group text-center border-light mb-1" role="group" aria-label="Quantity select">
|
||||
<button type="button"
|
||||
t-on-click = "() => props.onClick(false)"
|
||||
class="btn btn-light rounded-circle"> - </button>
|
||||
<div class="d-flex align-items-center px-4 fw-bold" t-esc="props.value"/>
|
||||
<button type="button"
|
||||
t-on-click = "() => props.onClick(true)"
|
||||
class="btn btn-light rounded-circle"> + </button>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class Loading extends Component {
|
||||
static template = "pos_self_order.Loading";
|
||||
static props = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.Loading" owl="1">
|
||||
<div class="d-flex align-items-center h-100 justify-content-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class MainButton extends Component {
|
||||
static template = "pos_self_order.MainButton";
|
||||
static props = { slots: Object, onClick: Function };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.MainButton" owl="1">
|
||||
<div class="d-flex flex-column align-items-center w-100 p-3 bg-white shadow-lg">
|
||||
<t t-slot="above"/>
|
||||
<button t-on-click="props.onClick" class="o_self_order_main_button btn btn-primary w-100 px-2 py-3 mt-2 rounded">
|
||||
<t t-slot="default"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
+6
-1
@@ -4,7 +4,12 @@ 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 };
|
||||
static props = {
|
||||
customText: { type: String, optional: true },
|
||||
goBack: { type: String },
|
||||
class: { type: String, optional: true },
|
||||
};
|
||||
static defaultProps = { class: "" };
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.o_self_order_navbar {
|
||||
// we have to set a max-height to make the transition work
|
||||
max-height: 300px;
|
||||
transition: all 0.25s linear;
|
||||
}
|
||||
|
||||
.o_self_order_navbar_hide {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?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="{{props.class}} o_self_order_navbar d-flex align-items-center bg-white border-bottom">
|
||||
<button class="btn btn-link text-dark"
|
||||
t-on-click="() => env.navigate(props.goBack)">
|
||||
<i class="fa fa-fw fa-chevron-left"/>
|
||||
</button>
|
||||
<div class="mx-auto overflow-hidden">
|
||||
<h3 class="my-3 text-primary text-truncate"
|
||||
t-esc="props.customText or selfOrder.company_name"
|
||||
t-on-click="() => env.navigate('/')"
|
||||
/>
|
||||
</div>
|
||||
<span class="px-3 text-center" t-esc="selfOrder?.table?.name"/>
|
||||
</nav>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
import { Component } from "@odoo/owl";
|
||||
import { ProductCard } from "@pos_self_order/Components/ProductCard/ProductCard";
|
||||
import { OrderLines } from "@pos_self_order/Components/OrderLines/OrderLines";
|
||||
import { PriceDetails } from "@pos_self_order/Components/PriceDetails/PriceDetails";
|
||||
export class Order extends Component {
|
||||
static template = "pos_self_order.Order";
|
||||
static components = { ProductCard, OrderLines, PriceDetails };
|
||||
static props = ["order"];
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.Order" owl="1">
|
||||
<div class="o_so_order d-flex flex-column">
|
||||
<div class="o_so_order_header p-3 bg-white">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<h6 class="m-0"
|
||||
t-esc="props.order.pos_reference"/>
|
||||
<span class="badge p-2 text-bg-primary"
|
||||
t-esc="props.order.state"/>
|
||||
</div>
|
||||
<p class="small m-0 fst-italic text-muted"
|
||||
t-esc="props.order.date"/>
|
||||
</div>
|
||||
<div class="o_so_order_body p-3 bg-300">
|
||||
<OrderLines t-if="props.order.items" lines="props.order.items"/>
|
||||
<div class="d-flex flex-column align-items-center px-3 pt-3 bg-white">
|
||||
<PriceDetails tax="props.order.amount_tax" total="props.order.amount_total"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,19 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
import { ProductCard } from "../ProductCard/ProductCard";
|
||||
|
||||
export class OrderLines extends Component {
|
||||
static template = "pos_self_order.OrderLines";
|
||||
static components = { ProductCard };
|
||||
static props = ["lines"];
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
}
|
||||
formatProduct(product) {
|
||||
return (({ description_sale, has_image, ...rest }) => ({ has_image: false, ...rest }))(
|
||||
product
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.OrderLines" owl="1">
|
||||
<t t-foreach="props.lines" t-as="orderLine" t-key="selfOrder.orderline_unique_keys.map((key) => orderLine[key])">
|
||||
<t t-set="product" t-value="selfOrder.getProduct({ id: orderLine.product_id })"/>
|
||||
<ProductCard product="formatProduct(product)"
|
||||
orderLine="orderLine"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
|
||||
export class PriceDetails extends Component {
|
||||
static template = "pos_self_order.PriceDetails";
|
||||
static props = { tax: Number, total: Number };
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.PriceDetails" owl="1">
|
||||
<div class="d-grid gap-2 w-100 w-100 pb-3">
|
||||
<div class="d-flex justify-content-between text-muted">
|
||||
Tax:
|
||||
<span t-esc="selfOrder.formatMonetary(props.tax)"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between fw-bold">
|
||||
Total:
|
||||
<span t-esc="selfOrder.formatMonetary(props.total)"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,55 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
import { useForwardRefToParent } from "@web/core/utils/hooks";
|
||||
|
||||
export class ProductCard extends Component {
|
||||
static template = "pos_self_order.ProductCard";
|
||||
static props = ["product", "orderLine?", "currentProductCard?", "class?"];
|
||||
static defaultProps = { class: "" };
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
useForwardRefToParent("currentProductCard");
|
||||
this.qtyInCart = !this.props.orderLine && this.getQtyInCart();
|
||||
}
|
||||
clickOnProduct() {
|
||||
const product = this.props.product;
|
||||
if (!this.canOpenProductMainView(product)) {
|
||||
return;
|
||||
}
|
||||
if (this.env.getCurrentRoute()[0] === "cart") {
|
||||
this.selfOrder.setCurrentlyEditedOrderLine(this.props?.orderLine);
|
||||
}
|
||||
this.env.navigate("/products/" + product.product_id);
|
||||
}
|
||||
|
||||
canOpenProductMainView(product) {
|
||||
return (
|
||||
this.selfOrder.table ||
|
||||
product.has_image ||
|
||||
product.description_sale ||
|
||||
product.attributes.length
|
||||
);
|
||||
}
|
||||
getQtyInCart() {
|
||||
const cart = this.selfOrder.cart;
|
||||
const product = this.props.product;
|
||||
return cart
|
||||
.filter((x) => x.product_id === product.product_id)
|
||||
.reduce((sum, x) => sum + x.qty, 0);
|
||||
}
|
||||
getQtyInCartString() {
|
||||
return `${this.props.orderLine.qty} x ${this.selfOrder.formatMonetary(
|
||||
this.props.product.price_info.list_price +
|
||||
(this.props.orderLine?.price_extra.list_price || 0)
|
||||
)}`;
|
||||
}
|
||||
getTotalPriceString() {
|
||||
return `${this.selfOrder.formatMonetary(
|
||||
(this.props.orderLine?.qty || 1) *
|
||||
(this.props.product.price_info.list_price +
|
||||
(this.props.orderLine?.price_extra.list_price || 0))
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
.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 !important;
|
||||
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,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.ProductCard" owl="1">
|
||||
<div
|
||||
t-attf-class="{{props.class}} o_self_order_item_card position-relative d-flex align-items-start w-100 bg-white px-3 py-2 overflow-hidden"
|
||||
t-on-click="clickOnProduct"
|
||||
t-ref="currentProductCard"
|
||||
>
|
||||
<div t-attf-class="d-flex {{ props.product.has_image ? 'flex-column' : 'flex-row justify-content-between' }} w-100 py-1">
|
||||
<div t-attf-class="d-flex {{ props.orderLine?.qty ? 'flex-column align-items-start' : 'flex-row align-items-center' }} text-900 fw-bold fs-6">
|
||||
<span
|
||||
class="d-flex align-items-center me-1 text-primary fw-bolder"
|
||||
t-if="qtyInCart"
|
||||
t-esc="`${qtyInCart}x`"
|
||||
/>
|
||||
<h3 class="mb-0"
|
||||
t-esc="props.product.name"
|
||||
/>
|
||||
<span
|
||||
t-if="props.orderLine?.qty"
|
||||
class="flex-grow-1 me-3 small text-muted"
|
||||
t-esc="getQtyInCartString()"
|
||||
/>
|
||||
<!-- this description represents the list of selected variants of the product -->
|
||||
<span
|
||||
t-if="props.orderLine?.description"
|
||||
class="m-0 text-muted small break-line"
|
||||
t-esc="props.orderLine.description"
|
||||
/>
|
||||
<div t-if="props.orderLine?.customer_note" class="d-inline-block m-0 text-muted small break-line">
|
||||
<i class="fa fa-pencil-square-o" aria-hidden="true" />
|
||||
<span t-esc="props.orderLine.customer_note" class="ms-1" />
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
t-attf-class="card-text small"
|
||||
t-esc="getTotalPriceString()"
|
||||
/>
|
||||
<p
|
||||
t-if="props.product.description_sale"
|
||||
class="o_self_order_item_card_description m-0 text-muted small break-line"
|
||||
t-esc="props.product.description_sale"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
t-if="props.product.has_image"
|
||||
class="o_self_order_item_card_image h-100 w-100 ms-2 rounded-3 border border-light bg-light"
|
||||
t-attf-src="/menu/get-image/{{ props.product.product_id }}"
|
||||
alt="Product image"
|
||||
loading="lazy"
|
||||
onerror="this.remove()"
|
||||
/>
|
||||
<span
|
||||
t-if="qtyInCart"
|
||||
class="position-absolute top-0 start-0 h-100 border-start border-5 border-primary"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,32 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, useEffect, useRef } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
|
||||
export class TagSelector extends Component {
|
||||
static template = "pos_self_order.TagSelector";
|
||||
static props = ["activeTag", "tagList", "onClick"];
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
this.tagButtons = Object.fromEntries(
|
||||
Array.from(this.selfOrder.tagList).map((tag) => {
|
||||
return [tag, useRef(`tag_${tag}`)];
|
||||
})
|
||||
);
|
||||
this.tagList = useRef("tagList");
|
||||
// we scroll the tag list horizontally so that the selected tag is in the middle of the screen
|
||||
useEffect(
|
||||
(activeTag) => {
|
||||
if (!activeTag) {
|
||||
return;
|
||||
}
|
||||
const tag = this.tagButtons[activeTag].el;
|
||||
this.tagList.el.scroll({
|
||||
left: tag.offsetLeft + tag.offsetWidth / 2 - window.innerWidth / 2,
|
||||
behavior: "smooth",
|
||||
});
|
||||
},
|
||||
() => [this.props.activeTag]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.TagSelector" owl="1">
|
||||
<ul t-ref="tagList" class="nav nav-pills flex-nowrap flex-grow-1 px-2 border-end overflow-auto">
|
||||
<t t-foreach="props.tagList" t-as="tag" t-key="tag">
|
||||
<li class="nav-item mx-1 py-2"
|
||||
t-on-click="() => props.onClick(tag)">
|
||||
<span
|
||||
t-attf-class="user-select-none nav-link py-2 rounded {{props.activeTag === tag ? 'active' : 'text-dark'}}"
|
||||
t-ref="tag_{{tag}}"
|
||||
t-esc="tag"/>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,93 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { useEffect, useState } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* This hook returns the target detected by the intersection observer.
|
||||
* @param {ref} root : reference to the element that is the root of the intersection observer
|
||||
* @param {Object} targets : object with references to each target
|
||||
* @param {()=>{[boolean]}} getDeps : function that returns a list with one item; this item should be true when the effect should be run and false when it should be stopped
|
||||
* @param {*} callback : function that is called when the tag is detected
|
||||
* @returns {{name:string}} : object with the name of the tag that is currently selected
|
||||
*/
|
||||
export function useDetection(root, targets, getDeps, callback = () => {}) {
|
||||
const detected = useState({ name: "" });
|
||||
useEffect((stop) => {
|
||||
if (stop) {
|
||||
return;
|
||||
}
|
||||
/** @type{IntersectionObserverEntry[]} */
|
||||
let entriesOnScreen = [];
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entriesOnScreen = updateEntries(entriesOnScreen, entries);
|
||||
detected.name = getName(getFirst(entriesOnScreen));
|
||||
callback(detected.name);
|
||||
},
|
||||
{
|
||||
root: root.el,
|
||||
rootMargin: "-100px",
|
||||
}
|
||||
);
|
||||
Object.keys(targets).forEach((tag) => {
|
||||
observer.observe(targets[tag]?.el);
|
||||
});
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, getDeps);
|
||||
|
||||
return detected;
|
||||
}
|
||||
/**
|
||||
* @param {IntersectionObserverEntry[]} entries
|
||||
* @returns {IntersectionObserverEntry} : the entry that is the highest on the page
|
||||
*/
|
||||
function getFirst(entries) {
|
||||
return getMax(entries, (entry) => entry.target.offsetTop);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} entries - The array of objects to search through.
|
||||
* @param {Function} [criterion=(x) => x] - A function that returns a number for each entry. The entry with the highest value of this function will be returned. If not provided, defaults to an identity function that returns the entry itself.
|
||||
* @param {boolean} [inverted=false] - If true, the entry with the lowest value of the criterion function will be returned instead.
|
||||
* @returns {T} The entry with the highest or lowest value of the criterion function, depending on the value of `inverted`.
|
||||
*/
|
||||
function getMax(entries, criterion = (x) => x, inverted = false) {
|
||||
return entries.reduce((prev, current) => {
|
||||
const res = criterion(prev) < criterion(current);
|
||||
return (inverted ? !res : res) ? prev : current;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IntersectionObserverEntry[]} entriesOnScreen
|
||||
* @param {IntersectionObserverEntry[]} entries
|
||||
* @returns {IntersectionObserverEntry[]}
|
||||
*/
|
||||
function updateEntries(entriesOnScreen, entries) {
|
||||
const entriesNoLongerIntersecting = entries.filter((entry) => !entry.isIntersecting);
|
||||
const newEntriesIntersecting = entries.filter((entry) => entry.isIntersecting);
|
||||
return [
|
||||
...entriesOnScreen.filter((entry) => notIncludes(entry, entriesNoLongerIntersecting)),
|
||||
...newEntriesIntersecting.filter((entry) => notIncludes(entry, entriesOnScreen)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IntersectionObserverEntry} entry
|
||||
* @param {IntersectionObserverEntry[]} entries
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function notIncludes(entry, entries) {
|
||||
return !entries.some((e) => getName(e) === getName(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IntersectionObserverEntry} item
|
||||
* @returns {string}: the title of the group
|
||||
*/
|
||||
function getName(item) {
|
||||
return item.target.querySelector("h3").textContent;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { useEffect, useState } from "@odoo/owl";
|
||||
|
||||
export function useScrollDirection(ref, callback = () => {}) {
|
||||
const scroll = useState({ down: false });
|
||||
useEffect(
|
||||
() => {
|
||||
const element = ref.el;
|
||||
const threshold = 60;
|
||||
let lastScrollY = element?.scrollTop;
|
||||
let ticking = false;
|
||||
const updateScrollDir = () => {
|
||||
const scrollY = element.scrollTop;
|
||||
const amountScrolled = Math.abs(scrollY - lastScrollY);
|
||||
if (amountScrolled < threshold) {
|
||||
ticking = false;
|
||||
return;
|
||||
}
|
||||
const scrollingDown = scrollY > lastScrollY;
|
||||
if (scrollingDown != scroll.down) {
|
||||
callback(scrollingDown);
|
||||
scroll.down = scrollingDown;
|
||||
}
|
||||
lastScrollY = scrollY > 0 ? scrollY : 0;
|
||||
ticking = false;
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!ticking) {
|
||||
// this makes the scrolling smooth
|
||||
window.requestAnimationFrame(updateScrollDir);
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
element.addEventListener("scroll", onScroll);
|
||||
return () => element.removeEventListener("scroll", onScroll);
|
||||
},
|
||||
() => []
|
||||
);
|
||||
|
||||
return scroll;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?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="oi oi-fw oi-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,26 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, useEffect } from "@odoo/owl";
|
||||
import { NavBar } from "@pos_self_order/Components/NavBar/NavBar";
|
||||
import { ProductCard } from "@pos_self_order/Components/ProductCard/ProductCard";
|
||||
import { OrderLines } from "@pos_self_order/Components/OrderLines/OrderLines";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
import { PriceDetails } from "@pos_self_order/Components/PriceDetails/PriceDetails";
|
||||
import { MainButton } from "@pos_self_order/Components/MainButton/MainButton";
|
||||
|
||||
export class CartView extends Component {
|
||||
static components = { NavBar, ProductCard, OrderLines, PriceDetails, MainButton };
|
||||
static props = [];
|
||||
static template = "pos_self_order.CartView";
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
useEffect(
|
||||
(cart) => {
|
||||
if (!cart.length) {
|
||||
this.env.navigate("/products");
|
||||
}
|
||||
},
|
||||
() => [this.selfOrder.cart]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.CartView" owl="1">
|
||||
<div class="d-flex flex-column dvh-100">
|
||||
<NavBar customText="'Cart'" goBack="'/products'"/>
|
||||
<main class="h-100 bg-100 overflow-auto">
|
||||
<OrderLines lines="selfOrder.cart"/>
|
||||
</main>
|
||||
<t t-call="pos_self_order.OrderButton"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.OrderButton" owl="1">
|
||||
<MainButton onClick="() => selfOrder.sendOrder()">
|
||||
<t t-set-slot="above">
|
||||
<PriceDetails tax="selfOrder.getTotalCartTax()" total="selfOrder.getTotalCartCost()"/>
|
||||
</t>
|
||||
Order
|
||||
</MainButton>
|
||||
</t>
|
||||
</templates>
|
||||
+2
-1
@@ -5,9 +5,10 @@ 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";
|
||||
static props = [];
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
this.formatMonetary = formatMonetary;
|
||||
this.env.state.currentProduct = 0;
|
||||
this.selfOrder.currentProduct = 0;
|
||||
}
|
||||
}
|
||||
+14
-5
@@ -3,7 +3,7 @@
|
||||
<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-image: linear-gradient(to bottom, transparent 33%, #000000 99%), url(/menu/get-bg-image/{{selfOrder.pos_config_id}});
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
">
|
||||
@@ -11,18 +11,28 @@
|
||||
<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-on-click="(event)=> link.url.startsWith('/') and env.navigate(link.url)"
|
||||
t-attf-class="btn btn-{{link.style}} py-3 my-1 rounded">
|
||||
<t t-esc="link.name"/>
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="selfOrder.orders.length">
|
||||
<hr class="bg-500"/>
|
||||
<a
|
||||
type="button"
|
||||
t-on-click="()=> env.navigate('/orders')"
|
||||
class="btn btn-outline-light py-3 my-2">
|
||||
My Orders
|
||||
</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">
|
||||
<span class="o_pos_self_order_odoo_logo ms-2 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">
|
||||
@@ -36,4 +46,3 @@
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
import { Order } from "@pos_self_order/Components/Order/Order";
|
||||
import { NavBar } from "@pos_self_order/Components/NavBar/NavBar";
|
||||
import { Loading } from "@pos_self_order/Components/Loading/Loading";
|
||||
export class OrdersView extends Component {
|
||||
static template = "pos_self_order.OrdersView";
|
||||
static components = { Order, NavBar, Loading };
|
||||
static props = [];
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
this.selfOrder.updateOrders();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.OrdersView" owl="1">
|
||||
<NavBar customText="'Orders'" goBack="'/'"/>
|
||||
<div class="overflow-auto dvh-100 pb-5">
|
||||
<t t-if="selfOrder.orders == null">
|
||||
<Loading/>
|
||||
</t>
|
||||
<div t-else="">
|
||||
<t t-foreach="selfOrder.orders" t-as="order" t-key="order.pos_reference">
|
||||
<Order order="order"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,121 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onMounted, useRef, useState } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
import { useAutofocus, useChildRef } from "@web/core/utils/hooks";
|
||||
import { NavBar } from "@pos_self_order/Components/NavBar/NavBar";
|
||||
import { ProductCard } from "@pos_self_order/Components/ProductCard/ProductCard";
|
||||
import { fuzzyLookup } from "@web/core/utils/search";
|
||||
import { useScrollDirection } from "@pos_self_order/Hooks/useScrollDirection";
|
||||
import { useDetection } from "@pos_self_order/Hooks/useDetection";
|
||||
import { Transition } from "@web/core/transition";
|
||||
import { TagSelector } from "@pos_self_order/Components/TagSelector/TagSelector";
|
||||
import { MainButton } from "@pos_self_order/Components/MainButton/MainButton";
|
||||
|
||||
export class ProductList extends Component {
|
||||
static template = "pos_self_order.ProductList";
|
||||
static props = [];
|
||||
static components = {
|
||||
NavBar,
|
||||
ProductCard,
|
||||
Transition,
|
||||
TagSelector,
|
||||
MainButton,
|
||||
};
|
||||
setup() {
|
||||
this.search = useState({
|
||||
isFocused: false,
|
||||
input: "",
|
||||
});
|
||||
this.selfOrder = useSelfOrder();
|
||||
useAutofocus({ refName: "input", mobile: true });
|
||||
this.productsList = useRef("productsList");
|
||||
|
||||
// reference to the last visited product
|
||||
// (used to scroll back to it when the user comes back from the product page)
|
||||
this.currentProductCard = useChildRef();
|
||||
|
||||
// object with references to each product group
|
||||
this.productGroups = Object.fromEntries(
|
||||
Array.from(this.selfOrder.tagList).map((tag) => {
|
||||
return [tag, useRef(`productsWithTag_${tag}`)];
|
||||
})
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// if the user is coming from the product page
|
||||
// we scroll back to the product card that he was looking at before
|
||||
if (this.selfOrder.currentProduct) {
|
||||
this.scrollTo(this.currentProductCard, { behavior: "instant" });
|
||||
}
|
||||
});
|
||||
// this is used to hide the navbar when the user is scrolling down
|
||||
this.scroll = useScrollDirection(this.productsList);
|
||||
|
||||
// This detects the product group where we are currently scrolled
|
||||
this.currentProductGroup = useDetection(this.productsList, this.productGroups, () => [
|
||||
this.search.isFocused,
|
||||
]);
|
||||
}
|
||||
|
||||
shouldNavbarBeShown() {
|
||||
return !this.search.isFocused && !this.scroll.down;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function scrolls the productsList to the ref passed as argument,
|
||||
* or to the top of the page if no ref is passed
|
||||
* @param {Object} ref - the ref to scroll to
|
||||
*/
|
||||
scrollTo(ref = null, { behavior = "smooth" } = {}) {
|
||||
// it would be convenient to use .scrollIntoView() but it doesn't work if we
|
||||
// scroll on multiple elements at the same time. ( when we scroll the productsList
|
||||
// we also scroll the tagList in the header)
|
||||
this.productsList.el.scroll({
|
||||
top: ref?.el ? ref.el.offsetTop - this.productsList.el.offsetTop : 0,
|
||||
behavior,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 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.search.input) {
|
||||
return this.selfOrder.products;
|
||||
}
|
||||
return fuzzyLookup(
|
||||
this.search.input,
|
||||
this.selfOrder.products,
|
||||
(product) => product.name + product.description_sale
|
||||
);
|
||||
}
|
||||
/**
|
||||
* This function is called when a tag is clicked;
|
||||
* @param {string} tag_name
|
||||
*/
|
||||
tagOnClick(tag_name) {
|
||||
// When the user clicks on a tag, we scroll to the part of the page
|
||||
// where the products with that tag are displayed.
|
||||
// We don't have to manually mark the tag as selected because the
|
||||
// useDetection hook will do it for us.
|
||||
this.scrollTo(
|
||||
this.currentProductGroup.name != tag_name ? this.productGroups[tag_name] : null
|
||||
);
|
||||
}
|
||||
/**
|
||||
* This function is called when the search button is clicked.
|
||||
*/
|
||||
focusSearch() {
|
||||
this.search.isFocused = true;
|
||||
this.scrollTo();
|
||||
}
|
||||
/**
|
||||
* This function is called when the 'Close Search' button is clicked ( the 'x' button )
|
||||
*/
|
||||
closeSearch() {
|
||||
this.search.isFocused = false;
|
||||
this.search.input = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="pos_self_order.ProductList" owl="1">
|
||||
<div class="d-flex flex-column dvh-100">
|
||||
<t t-call="pos_self_order.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 d-flex flex-row flex-wrap">
|
||||
<t t-if="search.isFocused" t-call="pos_self_order.SearchResults"/>
|
||||
<t t-else="" t-call="pos_self_order.productsGroupedByTag"/>
|
||||
</main>
|
||||
<!-- we use flex-grow-1 to push the button to the bottom of the page
|
||||
( normally there would be many products, so the page would be long enough to push the button to the bottom,
|
||||
but if there are no products (for example if you search for something that does not exist), the button
|
||||
would be at the top of the page )
|
||||
-->
|
||||
<div class="flex-grow-1"></div>
|
||||
<t t-if="selfOrder.table and selfOrder.cart.length" t-call="pos_self_order.GoToCartButton"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.Header" owl="1">
|
||||
<header class="bg-white shadow-sm">
|
||||
<NavBar class="shouldNavbarBeShown() ? '' : 'o_self_order_navbar_hide' " goBack="'/'"/>
|
||||
<div class="o_self_order_searchbar_filter d-flex justify-content-start align-items-center p-0 m-0 border-bottom">
|
||||
<t t-if="search.isFocused" t-call="pos_self_order.SearchBox"/>
|
||||
<TagSelector
|
||||
t-if="!search.isFocused"
|
||||
activeTag="currentProductGroup.name"
|
||||
tagList="Array.from(selfOrder.tagList)"
|
||||
onClick.bind="tagOnClick"
|
||||
/>
|
||||
<t t-if="!search.isFocused" t-call="pos_self_order.SearchIcon"/>
|
||||
</div>
|
||||
</header>
|
||||
</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.productsGroupedByTag" owl="1">
|
||||
<t t-set="tags" t-value="Object.keys(selfOrder.productsGroupedByTag)" />
|
||||
<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.productsGroupedByTag[tag].length" t-ref="productsWithTag_{{tag}}" class="w-100 d-flex flex-wrap">
|
||||
<h3 class="w-100 m-0 mt-n1 pb-3 pt-4 px-3 bg-light" t-esc="tag" />
|
||||
<t t-foreach="selfOrder.productsGroupedByTag[tag]" t-as="product" t-key="product.product_id">
|
||||
<!-- If this is the product that we last visited, we use currentProductCard to
|
||||
get a reference to it -->
|
||||
<ProductCard product="product"
|
||||
currentProductCard="product.product_id === selfOrder.currentProduct and currentProductCard"
|
||||
class="'w-md-50 w-xl-25'"/>
|
||||
</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">
|
||||
<ProductCard product="product"
|
||||
class="'w-md-50 w-xl-25'"/>
|
||||
</t>
|
||||
</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"
|
||||
placeholder="Pizza"
|
||||
t-model="search.input"/>
|
||||
<div class="px-2">
|
||||
<button class="btn bg-200 rounded" t-on-click="closeSearch">
|
||||
<i class="fa fa-times"/>
|
||||
</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"/>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.GoToCartButton" owl="1">
|
||||
<MainButton onClick="() => env.navigate('/cart')">
|
||||
<div class="d-flex align-items-center justify-content-between mx-2">
|
||||
<i class="fa fa-shopping-cart pe-2" aria-hidden="true"/>
|
||||
<p class="m-0">
|
||||
Review (
|
||||
<t t-esc="selfOrder.getTotalCartQty()" />
|
||||
)
|
||||
</p>
|
||||
<span class="ms-auto"
|
||||
t-esc="selfOrder.formatMonetary(selfOrder.getTotalCartCost())"
|
||||
/>
|
||||
</div>
|
||||
</MainButton>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,97 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillUnmount, useState } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/SelfOrderService";
|
||||
import { NavBar } from "@pos_self_order/Components/NavBar/NavBar";
|
||||
import { IncrementCounter } from "@pos_self_order/Components/IncrementCounter/IncrementCounter";
|
||||
import { MainButton } from "@pos_self_order/Components/MainButton/MainButton";
|
||||
export class ProductMainView extends Component {
|
||||
static template = "pos_self_order.ProductMainView";
|
||||
static props = { product: Object };
|
||||
static components = {
|
||||
NavBar,
|
||||
IncrementCounter,
|
||||
MainButton,
|
||||
};
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
|
||||
onWillUnmount(() => {
|
||||
this.selfOrder.currentlyEditedOrderLine = null;
|
||||
});
|
||||
|
||||
// we want to keep track of the last product that was viewed
|
||||
this.selfOrder.currentProduct = this.props.product.product_id;
|
||||
this.orderLine = useState({
|
||||
qty: this.selfOrder?.currentlyEditedOrderLine?.qty || 1,
|
||||
customer_note: this.selfOrder?.currentlyEditedOrderLine?.customer_note || "",
|
||||
selectedVariants: Object.fromEntries(
|
||||
this.props.product.attributes.map((attribute, key) => [
|
||||
attribute.name,
|
||||
this.selfOrder?.currentlyEditedOrderLine?.description?.split(", ")?.[key] ||
|
||||
attribute.values[0].name,
|
||||
])
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
incrementQty(up) {
|
||||
this.orderLine.qty = this.computeNewQty(this.orderLine.qty, up);
|
||||
}
|
||||
computeNewQty(qty, up) {
|
||||
if (up) {
|
||||
return qty + 1;
|
||||
}
|
||||
if (qty > 1) {
|
||||
return qty - 1;
|
||||
}
|
||||
if (this.selfOrder.currentlyEditedOrderLine) {
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
/**
|
||||
* @param {Object} selectedVariants
|
||||
* @param {import ("@pos_self_order/SelfOrderService").Attribute[]} attributes
|
||||
* @param {"list_price" | "price_with_tax" | "price_without_tax"} type
|
||||
* @returns {Number}
|
||||
*/
|
||||
getPriceExtra(selectedVariants, attributes, type = "list_price") {
|
||||
return (
|
||||
Object.entries(selectedVariants).reduce((sum, selected) => {
|
||||
return (
|
||||
sum +
|
||||
attributes
|
||||
.find((attribute) => attribute.name == selected[0])
|
||||
.values.find((value) => value.name == selected[1]).price_extra[type]
|
||||
);
|
||||
}, 0) || 0
|
||||
);
|
||||
}
|
||||
getAllPricesExtra(selectedVariants, attributes) {
|
||||
const getPriceExtra = (type) => this.getPriceExtra(selectedVariants, attributes, type);
|
||||
const priceTypes = ["list_price", "price_without_tax", "price_with_tax"];
|
||||
return Object.fromEntries(priceTypes.map((type) => [type, getPriceExtra(type)]));
|
||||
}
|
||||
|
||||
preFormOrderline() {
|
||||
const orderLine = this.orderLine;
|
||||
const product = this.props.product;
|
||||
return {
|
||||
product_id: product.product_id,
|
||||
customer_note: orderLine.customer_note,
|
||||
description: Object.values(orderLine.selectedVariants).join(", "),
|
||||
nonFinalQty: orderLine.qty,
|
||||
price_extra: this.getAllPricesExtra(orderLine.selectedVariants, product.attributes),
|
||||
};
|
||||
}
|
||||
|
||||
addToCartButtonClicked() {
|
||||
const preFormedOrderline = this.preFormOrderline();
|
||||
this.selfOrder.updateCart(preFormedOrderline);
|
||||
this.env.navigate(this.returnRoute());
|
||||
}
|
||||
returnRoute() {
|
||||
return this.selfOrder.currentlyEditedOrderLine ? "/cart" : "/products";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.ProductMainView" owl="1">
|
||||
|
||||
<div class="d-flex flex-column dvh-100">
|
||||
<NavBar goBack="returnRoute()"/>
|
||||
<div class="bg-white d-flex flex-column pt-3 overflow-auto h-100">
|
||||
<div class="px-3">
|
||||
<img t-if="props.product.has_image"
|
||||
class="w-100 rounded-3"
|
||||
style="max-height: 400px; object-fit: cover;"
|
||||
t-attf-src="/menu/get-image/{{ props.product.product_id }}/1920"
|
||||
alt="Product image"/>
|
||||
<div class="pt-3">
|
||||
<h3 t-esc="props.product.name"
|
||||
class="o_self_order_product_main_view_name"
|
||||
/>
|
||||
<h4 class="text-primary"
|
||||
t-esc="selfOrder.formatMonetary(props.product.price_info.list_price)"/>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="props.product.description_sale"
|
||||
class="o_self_order_main_desc px-3 pb-3 text-muted"
|
||||
t-esc="props.product.description_sale"
|
||||
/>
|
||||
<div class="o_self_order_main_options px-3 py-4 border-bottom bg-100">
|
||||
<t t-if="props.product.attributes"
|
||||
t-foreach="props.product.attributes"
|
||||
t-as="attribute"
|
||||
t-key="attribute.id">
|
||||
<div class="mb-3">
|
||||
<div class="attribute_name py-1 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'" />
|
||||
</div>
|
||||
</t>
|
||||
<div t-if="selfOrder.table">
|
||||
<label class="form-label fw-bold" for="note">Add a note:</label>
|
||||
<textarea class="form-control rounded bg-white" id="note" type="textarea" rows="1" placeholder="No onions please" t-model="orderLine.customer_note"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="selfOrder.table" t-call="pos_self_order.AddToCartButton"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.RadioProductAttribute" owl="1">
|
||||
<t t-foreach="attribute.values" t-as="value" t-key="value.id">
|
||||
<div class="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-att-value="value.name"
|
||||
t-attf-id="{{ attribute.id }}_{{ value.id }}"
|
||||
t-model="orderLine.selectedVariants[attribute.name]"
|
||||
/>
|
||||
<span class="flex-grow-1">
|
||||
<t t-esc="value.name"/>
|
||||
<t t-if="value.price_extra.list_price">
|
||||
+ <t t-esc="selfOrder.formatMonetary(value.price_extra.list_price)" />
|
||||
</t>
|
||||
</span>,
|
||||
nonFinalQty: orderLine.qty,
|
||||
price_extra: this.getAllPricesExtra(orderLine.selectedVariants, product.attributes),
|
||||
};
|
||||
}
|
||||
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.SelectProductAttribute" owl="1">
|
||||
<select class="configurator_select form-select"
|
||||
t-model="orderLine.selectedVariants[attribute.name]">
|
||||
<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.list_price">
|
||||
+ <t t-esc="selfOrder.formatMonetary(value.price_extra.list_price)" />
|
||||
</t>
|
||||
</option>
|
||||
</select>
|
||||
</t>
|
||||
|
||||
<t t-name="pos_self_order.ColorProductAttribute" owl="1">
|
||||
<div>
|
||||
<ul>
|
||||
<li t-foreach="attribute.values" t-as="value" t-key="value.id">
|
||||
<label
|
||||
t-attf-style="background-color: {{ value.html_color }};"
|
||||
t-att-data-color="value.name">
|
||||
<input
|
||||
type="radio"
|
||||
class="form-check-input my-1 me-2"
|
||||
t-att-name="attribute.id"
|
||||
t-att-value="value.name"
|
||||
t-attf-id="{{ attribute.id }}_{{ value.id }}"
|
||||
t-model="orderLine.selectedVariants[attribute.name]"
|
||||
/>
|
||||
</label>
|
||||
<span class="flex-grow-1">
|
||||
<t t-esc="value.name"/>
|
||||
<t t-if="value.price_extra.list_price">
|
||||
+ <t t-esc="selfOrder.formatMonetary(value.price_extra.list_price)" />
|
||||
</t>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="pos_self_order.AddToCartButton" owl="1">
|
||||
<MainButton onClick.bind="addToCartButtonClicked">
|
||||
<t t-set-slot="above">
|
||||
<IncrementCounter onClick.bind="incrementQty" value="this.orderLine.qty"/>
|
||||
</t>
|
||||
<t t-if="selfOrder.currentlyEditedOrderLine">
|
||||
Update Cart
|
||||
</t>
|
||||
<t t-else="">
|
||||
Add to Cart
|
||||
</t>
|
||||
</MainButton>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,235 +0,0 @@
|
||||
/** @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?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>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/** @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 };
|
||||
@@ -1,90 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,78 +1,47 @@
|
||||
/** @odoo-module */
|
||||
import { Component, whenReady, App, useState, useSubEnv } from "@odoo/owl";
|
||||
import { Component, whenReady, App } 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 { LandingPage } from "@pos_self_order/Pages/LandingPage/LandingPage";
|
||||
import { NavBar } from "@pos_self_order/Components/NavBar/NavBar";
|
||||
import { ProductMainView } from "@pos_self_order/Pages/ProductMainView/ProductMainView";
|
||||
import { CartView } from "@pos_self_order/Pages/CartView/CartView";
|
||||
import { ProductList } from "@pos_self_order/Pages/ProductList/ProductList";
|
||||
import { OrdersView } from "@pos_self_order/Pages/OrdersView/OrdersView";
|
||||
import { Router } from "@pos_self_order/router";
|
||||
import { groupBy } from "@web/core/utils/arrays";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { useSelfOrder } from "./SelfOrderService";
|
||||
|
||||
class SelfOrderRoot extends Component {
|
||||
static template = "pos_self_order.SelfOrderRoot";
|
||||
static props = [];
|
||||
static components = {
|
||||
LandingPage,
|
||||
ProductMainView,
|
||||
NavBar,
|
||||
ProductList,
|
||||
Router,
|
||||
CartView,
|
||||
OrdersView,
|
||||
MainComponentsContainer,
|
||||
};
|
||||
/*
|
||||
This is the Root Component of the SelfOrder App
|
||||
Most of the business logic is done here
|
||||
The app has the folowing screens:
|
||||
The app has the following screens:
|
||||
0. LandingPage -- the main screen of the app
|
||||
-- it has a button that redirects to the menu
|
||||
-- it has a button that redirects to the orders, if there are any
|
||||
|
||||
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 )
|
||||
3. CartView -- the screen that shows the cart
|
||||
4. OrdersView -- the screen that shows the list of orders
|
||||
*/
|
||||
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();
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
</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)"/>
|
||||
product="selfOrder.getProduct({ id: url.productId })"/>
|
||||
</t>
|
||||
<t t-set-slot="/menu/{string:pos_name}/cart">
|
||||
<CartView/>
|
||||
</t>
|
||||
<t t-set-slot="/menu/{string:pos_name}/orders">
|
||||
<OrdersView/>
|
||||
</t>
|
||||
</Router>
|
||||
<MainComponentsContainer/>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
|
||||
@@ -3,14 +3,396 @@ import { useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { session } from "@web/session";
|
||||
import { formatMonetary } from "@web/views/fields/formatters";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { groupBy } from "@web/core/utils/arrays";
|
||||
import { effect } from "@point_of_sale/utils";
|
||||
|
||||
const selfOrderService = {
|
||||
dependencies: ["rpc"],
|
||||
async start(env, { rpc }) {
|
||||
const object = {
|
||||
...session.pos_self_order,
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {{ [k in keyof T]: T[k] } & {}} Prettify
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ReducedOrder
|
||||
* @property {string} pos_reference
|
||||
* @property {string} access_token
|
||||
* @property {string} [state]
|
||||
*
|
||||
/**
|
||||
* @typedef {Prettify<ReducedOrder & {state: string, lines: OrderLine[], amount_total: number, amount_tax: number}>} Order
|
||||
*/
|
||||
|
||||
/**
|
||||
* The type of orderline that we get from the Product Main View
|
||||
* It contains the keys necessary to uniqely identify an orderline
|
||||
* @typedef {Object} PreFormedOrderLine
|
||||
* @property {number} product_id
|
||||
* @property {string} customer_note
|
||||
* @property {string} description
|
||||
* @property {number} nonFinalQty
|
||||
* @property {price_extra} PriceInfo
|
||||
*/
|
||||
|
||||
/**
|
||||
* The type of orderline that we send to the server
|
||||
* @typedef {Object} ReducedOrderLine
|
||||
* @property {number} product_id
|
||||
* @property {number} qty
|
||||
* @property {string} customer_note
|
||||
* @property {string} description
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Prettify<ReducedOrderLine & {price_extra: PriceInfo}>} OrderLine
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PriceInfo
|
||||
* @property {number} list_price
|
||||
* @property {number} price_with_tax
|
||||
* @property {number} price_without_tax
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Product
|
||||
* @property {number} product_id
|
||||
* @property {PriceInfo} price_info
|
||||
* @property {string} tag
|
||||
* @property {string} name
|
||||
* @property {string} description_sale
|
||||
* @property {boolean} has_image
|
||||
* @property {Attribute[]} attributes
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Attribute
|
||||
* @property {string} display_type - The type of display for the attribute.
|
||||
* @property {number} id - The unique identifier of the attribute.
|
||||
* @property {string} name - The name of the attribute.
|
||||
* @property {Value[]} values
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Value - An array of objects representing the attribute values.
|
||||
* @property {bool | string} value.html_color - False if the value has no color, otherwise the color in hex format. ( ex: #FF0000 )
|
||||
* @property {number} value.id - The unique identifier of the value.
|
||||
* @property {boolean} value.is_custom
|
||||
* @property {string} value.name
|
||||
* @property {PriceInfo} value.price_extra
|
||||
*/
|
||||
|
||||
export class SelfOrder {
|
||||
constructor(...args) {
|
||||
this.setup(...args);
|
||||
}
|
||||
setup(env, rpc, notification) {
|
||||
Object.assign(this, {
|
||||
env,
|
||||
rpc,
|
||||
notification,
|
||||
...session.pos_self_order_data,
|
||||
// Global state
|
||||
currentProduct: 0,
|
||||
/** @type {OrderLine[]} */
|
||||
cart: JSON.parse(localStorage.getItem("cart")) ?? [],
|
||||
/** @type {ReducedOrder[] | Order[]} */
|
||||
orders: JSON.parse(localStorage.getItem("orders")) ?? [],
|
||||
currentlyEditedOrderLine: null,
|
||||
});
|
||||
|
||||
// we create a set with all the tags that are present in the menu
|
||||
this.tagList = new Set(this.products.map((product) => product.tag));
|
||||
this.productsGroupedByTag = groupBy(this.products, "tag");
|
||||
// We want to keep a backup of some of the state in local storage
|
||||
effect(
|
||||
(state) => {
|
||||
Array.isArray(state.cart) &&
|
||||
localStorage.setItem("cart", JSON.stringify(state.cart));
|
||||
},
|
||||
[this]
|
||||
);
|
||||
effect(
|
||||
(state) => {
|
||||
Array.isArray(state.orders) &&
|
||||
localStorage.setItem("orders", JSON.stringify(state.orders));
|
||||
},
|
||||
[this]
|
||||
);
|
||||
if (!this.has_active_session) {
|
||||
this.closeNotification = this.notification.add(
|
||||
_t("The restaurant is closed. You can browse the menu, but ordering is disabled."),
|
||||
{ type: "warning" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OrderLine} orderLine
|
||||
*/
|
||||
setCurrentlyEditedOrderLine(orderLine) {
|
||||
this.currentlyEditedOrderLine = orderLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} price
|
||||
* @returns {number}
|
||||
*/
|
||||
formatMonetary(price) {
|
||||
return formatMonetary(price, { currencyId: this.currency_id });
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
getTotalCartQty() {
|
||||
const cart = this.cart;
|
||||
return cart.reduce((sum, orderLine) => {
|
||||
return sum + orderLine.qty;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
getTotalCartCost() {
|
||||
const cart = this.cart;
|
||||
return cart.reduce((sum, orderLine) => {
|
||||
return (
|
||||
sum +
|
||||
(this.getProduct({ id: orderLine.product_id }).price_info.price_with_tax +
|
||||
orderLine.price_extra.price_with_tax) *
|
||||
orderLine.qty
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
getTotalCartTax() {
|
||||
return this._getTotalCartTax(this.cart);
|
||||
}
|
||||
/**
|
||||
* From the server, for each product we get both the price with and without tax.
|
||||
* We never actually compute taxes on the frontend.
|
||||
* Here we add up the tax for each product in the cart
|
||||
* @param {OrderLine[]} cart
|
||||
* @returns {number}
|
||||
*/
|
||||
_getTotalCartTax(cart) {
|
||||
return cart.reduce((sum, orderLine) => {
|
||||
const product = this.getProduct({ id: orderLine.product_id });
|
||||
const getTax = (x) => x.price_with_tax - x.price_without_tax;
|
||||
return (
|
||||
sum + (getTax(product.price_info) + getTax(orderLine.price_extra)) * orderLine.qty
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PreFormedOrderLine} preFormedOrderline
|
||||
*/
|
||||
updateCart(preFormedOrderline) {
|
||||
const orderLineToReplace = this.currentlyEditedOrderLine;
|
||||
this.deleteOrderLine(orderLineToReplace);
|
||||
const orderLine = this.constructOrderline(preFormedOrderline);
|
||||
this.cart = this.getUpdatedCart(this.cart, orderLine);
|
||||
}
|
||||
|
||||
deleteOrderLine(orderLine) {
|
||||
if (!orderLine) {
|
||||
return;
|
||||
}
|
||||
this.cart = this.cart.filter((x) => JSON.stringify(x) !== JSON.stringify(orderLine));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cart with the updated item
|
||||
* if we are editing an orderline, instead of trying to change the existing orderline
|
||||
* we delete it and add a new one
|
||||
* @param {OrderLine[]} cart
|
||||
* @param {OrderLine} orderLine
|
||||
* @returns {OrderLine[]}
|
||||
*/
|
||||
getUpdatedCart(cart, orderLine) {
|
||||
return cart
|
||||
.filter((item) => !this.canBeMerged(item, orderLine))
|
||||
.concat(orderLine.qty ? [orderLine] : []);
|
||||
}
|
||||
/**
|
||||
* @param {OrderLine} orderline1
|
||||
* @param {OrderLine} orderline2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canBeMerged(orderline1, orderline2) {
|
||||
return (
|
||||
this.getProduct({ id: orderline1.product_id }).is_pos_groupable &&
|
||||
this.orderline_unique_keys.every((key) => orderline1[key] === orderline2[key])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PreFormedOrderLine} preFormedOrderline
|
||||
* @returns {OrderLine?}
|
||||
*/
|
||||
findMergeableOrderLine(preFormedOrderline) {
|
||||
return this.cart.find((item) => this.canBeMerged(item, preFormedOrderline));
|
||||
}
|
||||
|
||||
/**
|
||||
* The selfOrder.updateCart method expects us to give it the
|
||||
* total qty the orderline should have.
|
||||
* If we are currently editing an existing orderline ( that means that we came to this
|
||||
* page from the cart page), it means that we are editing the total qty itself,
|
||||
* so we just return orderLine.qty.
|
||||
* If we came to this page from the products page, it means that we are adding items,
|
||||
* so we need to add the qty of the current product to the qty that is
|
||||
* already in the cart.
|
||||
* @param {PreFormedOrderLine} preFormedOrderline
|
||||
* @returns {number}
|
||||
*/
|
||||
findOrderlineQty(preFormedOrderline) {
|
||||
// ordeline.qty can only be 0 if we are editing an existing orderline
|
||||
// If we are editing an existing orderline, we don't care about the
|
||||
// qty that might have already been in the cart.
|
||||
// This is why we only look at what quantity might already be in the cart
|
||||
// if orderline.qty != 0
|
||||
const qtyAlreadyInCart =
|
||||
preFormedOrderline.nonFinalQty &&
|
||||
(this.findMergeableOrderLine(preFormedOrderline)?.qty || 0);
|
||||
return qtyAlreadyInCart + preFormedOrderline.nonFinalQty;
|
||||
}
|
||||
/**
|
||||
* @param {PreFormedOrderLine}
|
||||
* @returns {OrderLine}
|
||||
*/
|
||||
constructOrderline(preFormedOrderline) {
|
||||
const qty = this.findOrderlineQty(preFormedOrderline);
|
||||
return (({ nonFinalQty, ...rest }) => ({
|
||||
qty,
|
||||
...rest,
|
||||
}))(preFormedOrderline);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {number} options.id
|
||||
* @returns {Product}
|
||||
*/
|
||||
getProduct({ id }) {
|
||||
return this.products.find((product) => product.product_id === id);
|
||||
}
|
||||
|
||||
////// Orders Logic /////////
|
||||
|
||||
/**
|
||||
* @param {ReducedOrder[] |Order[]} orders
|
||||
* @param {ReducedOrder | Order} new_order
|
||||
* @returns {ReducedOrder[] | Order[]}
|
||||
*/
|
||||
combineOrders(orders, new_order) {
|
||||
// We only want to keep the last 10 orders
|
||||
return [
|
||||
new_order,
|
||||
...orders
|
||||
.filter((order) => order.pos_reference !== new_order.pos_reference)
|
||||
.slice(0, 9),
|
||||
];
|
||||
}
|
||||
|
||||
async sendOrder() {
|
||||
try {
|
||||
/*
|
||||
If this is the first time the user is sending an order
|
||||
we just send the order items to the server
|
||||
If this is not the first time the user is sending an order
|
||||
( the user is adding more items to an existing order )
|
||||
we send the order items along with the order id and access_token to the server
|
||||
*/
|
||||
/**@type {ReducedOrder} */
|
||||
const postedOrder = await this.rpc(`/pos-self-order/send-order`, this.getOrderData());
|
||||
this.orders = this.combineOrders(this.orders, postedOrder);
|
||||
this.notification.add(_t("Your order has been placed!"), { type: "success" });
|
||||
// we only want to clear the cart if the order was sent successfully;
|
||||
// in the case of an unsuccessful order the user might want to try again
|
||||
this.cart = [];
|
||||
} catch (error) {
|
||||
this.notification.add(_t("Error sending order"), { type: "danger" });
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.navigate("/");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @returns {{pos_config_id: number, cart: ReducedOrderLine[], table_access_token: string, order_pos_reference: string, order_access_token: string}}}
|
||||
*/
|
||||
getOrderData() {
|
||||
return {
|
||||
pos_config_id: this.pos_config_id,
|
||||
cart: this.extractCartData(this.cart),
|
||||
table_access_token: this?.table?.access_token,
|
||||
// The last order is always the first one in the array
|
||||
// The orders are kept in reverse chronological order
|
||||
order_pos_reference: this.orders?.[0]?.pos_reference,
|
||||
order_access_token: this.orders?.[0]?.access_token,
|
||||
};
|
||||
return object;
|
||||
}
|
||||
/**
|
||||
* @param {OrderLine[]} cart
|
||||
* @returns {ReducedOrderLine[]}
|
||||
*/
|
||||
extractCartData(cart) {
|
||||
return cart.map((orderLine) => ({
|
||||
product_id: orderLine.product_id,
|
||||
qty: orderLine.qty,
|
||||
description: orderLine.description,
|
||||
customer_note: orderLine.customer_note,
|
||||
}));
|
||||
}
|
||||
|
||||
async updateOrders() {
|
||||
const old_orders = this.orders;
|
||||
// we set this to null so we have a way to see in the template
|
||||
// that we are still loading the orders
|
||||
this.orders = null;
|
||||
// This await is needed;
|
||||
this.orders = await this.getUpdatedOrdersFromServer(old_orders);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReducedOrder[] | Order[]} old_orders_list
|
||||
* @returns {ReducedOrder[] | Order[]}
|
||||
*/
|
||||
async getUpdatedOrdersFromServer(old_orders_list) {
|
||||
return await Promise.all(
|
||||
old_orders_list.map(async (order) =>
|
||||
order.state === "paid" || order.state === "done"
|
||||
? order
|
||||
: this.getUpdatedOrderFromServer(order)
|
||||
)
|
||||
);
|
||||
}
|
||||
/**
|
||||
* @param {ReducedOrder | Order} old_orders_list
|
||||
* @returns {ReducedOrder | Order}
|
||||
*/
|
||||
async getUpdatedOrderFromServer(order) {
|
||||
try {
|
||||
return await this.rpc(`/pos-self-order/view-order/`, {
|
||||
pos_reference: order.pos_reference,
|
||||
access_token: order.access_token,
|
||||
});
|
||||
} catch {
|
||||
return { ...order, state: "not found" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const selfOrderService = {
|
||||
dependencies: ["rpc", "notification"],
|
||||
async start(env, { rpc, notification }) {
|
||||
return new SelfOrder(env, rpc, notification);
|
||||
},
|
||||
};
|
||||
registry.category("services").add("self_order", selfOrderService);
|
||||
|
||||
@@ -1,50 +1,18 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
// overflow: none;
|
||||
position: fixed;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.break-line {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
.dvh-100{
|
||||
// we specify the 100vh as a fallback in case the browser does not
|
||||
// know dvh
|
||||
height: 100vh !important;
|
||||
height: 100dvh !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
import { useSelfOrder } from "./SelfOrderService";
|
||||
|
||||
function parseParams(matches, paramSpecs) {
|
||||
return Object.fromEntries(
|
||||
@@ -21,9 +22,15 @@ function parseParams(matches, paramSpecs) {
|
||||
);
|
||||
}
|
||||
export class Router extends Component {
|
||||
static props = { slots: Object, pos_config_id: Number };
|
||||
static template = xml`<t t-slot="{{state.activeSlot}}" t-props="state.slotProps"/>`;
|
||||
setup() {
|
||||
useSubEnv({ navigate: this.navigate.bind(this) });
|
||||
this.selfOrder = useSelfOrder();
|
||||
this.selfOrder.navigate = this.navigate.bind(this);
|
||||
useSubEnv({
|
||||
navigate: this.navigate.bind(this),
|
||||
getCurrentRoute: this.getCurrentRoute.bind(this),
|
||||
});
|
||||
this.state = useState({
|
||||
activeSlot: "default",
|
||||
slotProps: {},
|
||||
@@ -69,18 +76,23 @@ export class Router extends Component {
|
||||
/**
|
||||
* 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)
|
||||
* (this means that we don't make additional requests to the server)
|
||||
* @param {string} route
|
||||
* @param {Event} event
|
||||
* @param {number} pos_config_id
|
||||
*/
|
||||
navigate(route, event = null) {
|
||||
if (!route.startsWith("/")) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault();
|
||||
navigate(route, pos_config_id = this.props.pos_config_id) {
|
||||
const url = new URL(browser.location.href);
|
||||
url.pathname = `menu/${this.props.pos_config_id}${route}`;
|
||||
url.pathname = `menu/${pos_config_id}${route}`;
|
||||
history.pushState({}, "", url);
|
||||
this.matchURL();
|
||||
}
|
||||
/**
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getCurrentRoute() {
|
||||
const baseLength = "/menu/{string:pos_name}".split("/").length;
|
||||
// The base part of the route is there on all routes, so it
|
||||
// makes no sense to have it in the return
|
||||
return this.state.activeSlot.split("/").splice(baseLength);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-10
@@ -1,19 +1,24 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { clickOn } from "./tour_utils";
|
||||
|
||||
registry.category("web_tour.tours").add("pos_self_order_tour", {
|
||||
registry.category("web_tour.tours").add("pos_qr_menu_tour", {
|
||||
test: true,
|
||||
steps: [
|
||||
{
|
||||
content: "Test that the default View Menu button is present",
|
||||
trigger: ".btn",
|
||||
content: "Close the `Pos is Closed` notification",
|
||||
trigger: "button.o_notification_close",
|
||||
},
|
||||
...clickOn("My Orders", { isCheck: true, isNot: true }),
|
||||
...clickOn("View Menu"),
|
||||
{
|
||||
content: "Test that products are present",
|
||||
trigger: ".o_self_order_item_card",
|
||||
isCheck: true,
|
||||
},
|
||||
...clickOn("Add to Cart", { isCheck: true, isNot: true }),
|
||||
|
||||
{
|
||||
content: "Test that the 'No products found' message is not present",
|
||||
trigger: "body:not(:has(p:contains('No products found')))",
|
||||
@@ -105,14 +110,15 @@ registry.category("web_tour.tours").add("pos_self_order_tour", {
|
||||
},
|
||||
{
|
||||
content: "Test that the product description is present and click on it",
|
||||
trigger: "p.text-muted.small.break-line",
|
||||
trigger: "p.o_self_order_item_card_description",
|
||||
},
|
||||
// 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",
|
||||
trigger: ".o_self_order_product_main_view_name",
|
||||
isCheck: true,
|
||||
},
|
||||
...clickOn("Add", { isCheck: true, isNot: true }),
|
||||
{
|
||||
content: "Test that the back button is present on the product screen and click on it",
|
||||
trigger: "nav.o_self_order_navbar > button",
|
||||
@@ -125,11 +131,7 @@ registry.category("web_tour.tours").add("pos_self_order_tour", {
|
||||
},
|
||||
// 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,
|
||||
},
|
||||
...clickOn("View Menu", { 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))",
|
||||
@@ -0,0 +1,64 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { clickOn, clickOnBackButton, clickOnProductCard, addProductsToCart } from "./tour_utils";
|
||||
|
||||
registry.category("web_tour.tours").add("self_order_tour", {
|
||||
test: true,
|
||||
steps: [
|
||||
{
|
||||
content: "Check that the `Pos is Closed` notification is not present",
|
||||
trigger: "body:not(:has(.o_notification_content:contains(restaurant is closed)))",
|
||||
isCheck: true,
|
||||
},
|
||||
...clickOn("My Orders", { isCheck: true, isNot: true }),
|
||||
...clickOn("View Menu"),
|
||||
// We should now be on the product list screen
|
||||
...addProductsToCart([1, 2]),
|
||||
// this will allow us to test if the merging of orderlines works
|
||||
...addProductsToCart([1]),
|
||||
...clickOn("Review"),
|
||||
|
||||
// check that the products are in the card
|
||||
...[1, 2].map((id) => clickOnProductCard(id, { isCheck: true })).flat(),
|
||||
|
||||
...clickOnProductCard(1, { isCheck: true, qty: 2 }),
|
||||
|
||||
...clickOn("Order"),
|
||||
// We should now be on the landing page screen ( because ordering redirects to the landing page )
|
||||
...clickOn("My Orders"),
|
||||
{
|
||||
content: "Check if the status of the first order is `Draft`",
|
||||
trigger: "span.badge:contains('draft')",
|
||||
isCheck: true,
|
||||
},
|
||||
...[1, 2].map((id) => clickOnProductCard(id, { isCheck: true })).flat(),
|
||||
|
||||
...clickOnProductCard(1, { isCheck: true, qty: 2 }),
|
||||
|
||||
...clickOnBackButton(),
|
||||
// We should now be on the Landing Page
|
||||
|
||||
// We will now repeat the same steps as above, ordering again.
|
||||
// The idea is to test that the previous order is not present in the cart
|
||||
// and that the previous order is present in the `My Orders` screen
|
||||
// along with the new order.
|
||||
|
||||
...clickOn("View Menu"),
|
||||
// We should now be on the product list screen
|
||||
...addProductsToCart([2, 3, 4]),
|
||||
...clickOn("Review"),
|
||||
// We should now be on the cart screen
|
||||
...[1].map((id) => clickOnProductCard(id, { isCheck: true, isNot: true })).flat(),
|
||||
...[2, 3, 4].map((id) => clickOnProductCard(id, { isCheck: true })).flat(),
|
||||
|
||||
...clickOn("Order"),
|
||||
// We should now be on the landing page screen
|
||||
...clickOn("My Orders"),
|
||||
// We should now be on the orders screen
|
||||
|
||||
// if the product 2 has qty 2, then the order was merged correctly
|
||||
...[1, 2].map((id) => clickOnProductCard(id, { isCheck: true, qty: 2 })).flat(),
|
||||
...[3, 4].map((id) => clickOnProductCard(id, { isCheck: true, qty: 1 })).flat(),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/** @odoo-module */
|
||||
|
||||
// HELPERS ////////////////////////////////
|
||||
// Each of the export functions below returns an array of steps
|
||||
// (even those that return a single step, for consistency)
|
||||
|
||||
/**
|
||||
* START: product list screen
|
||||
* END: product list screen
|
||||
* @param {number[]} product_ids
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function addProductsToCart(product_ids) {
|
||||
return product_ids
|
||||
.map((id) => [
|
||||
...clickOnProductCard(id),
|
||||
// We should now be on the product main view screen
|
||||
...clickOn("Add"),
|
||||
// We should now be on the product list screen
|
||||
])
|
||||
.flat();
|
||||
}
|
||||
/**
|
||||
* @param {string} selector
|
||||
* @returns {string}
|
||||
*/
|
||||
export function doesNotExist(selector) {
|
||||
return `body:not(:has(${selector}))`;
|
||||
}
|
||||
|
||||
export function clickOnProductCard(
|
||||
product_id,
|
||||
{ isCheck = false, isNot = false, qty = false } = {}
|
||||
) {
|
||||
let productCard = `.o_self_order_item_card:contains(Product ${product_id} test)`;
|
||||
if (qty) {
|
||||
productCard = `${productCard}:has(span:contains(${qty} x ))`;
|
||||
}
|
||||
return [
|
||||
{
|
||||
content: `${isCheck ? "Test" : "Click"} the product card of Product ${product_id}${
|
||||
qty ? ` with quantity ${qty}` : ""
|
||||
}${isNot ? " (should not present)" : ""}`,
|
||||
trigger: isNot ? doesNotExist(productCard) : productCard,
|
||||
isCheck,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function clickOnBackButton() {
|
||||
return [
|
||||
{
|
||||
content: "Click the navbar back button",
|
||||
trigger: "nav.o_self_order_navbar > button",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function clickOn(element, { isCheck = false, isNot = false } = {}) {
|
||||
const selector = `.btn:contains('${element}')`;
|
||||
return [
|
||||
{
|
||||
content: `Click on '${element}' button`,
|
||||
trigger: isNot ? doesNotExist(selector) : selector,
|
||||
isCheck,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -5,32 +5,43 @@ import odoo.tests
|
||||
|
||||
|
||||
@odoo.tests.tagged("post_install", "-at_install")
|
||||
class TestFrontend(odoo.tests.HttpCase):
|
||||
class TestFrontendMobile(odoo.tests.HttpCase):
|
||||
browser_size = "375x667"
|
||||
touch_enabled = True
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.pos_config = self.env["pos.config"].create(
|
||||
{
|
||||
"name": "Bar",
|
||||
"name": "BarTest",
|
||||
"module_pos_restaurant": True,
|
||||
"iface_splitbill": True,
|
||||
"iface_printbill": True,
|
||||
"iface_orderline_notes": True,
|
||||
"start_category": True,
|
||||
"self_order_view_mode": True,
|
||||
"self_order_table_mode": False,
|
||||
}
|
||||
)
|
||||
basic_product = {
|
||||
"name": "Test Product",
|
||||
basic_product = lambda i: {
|
||||
"name": f"Product {i} test",
|
||||
"type": "product",
|
||||
"available_in_pos": True,
|
||||
"list_price": 10,
|
||||
"list_price": i,
|
||||
"taxes_id": False,
|
||||
}
|
||||
self.env["product.product"].create([basic_product for i in range(1000)])
|
||||
self.env["product.product"].create([basic_product(i) for i in range(1, 1000)])
|
||||
|
||||
def test_self_order_tour(self):
|
||||
def test_self_order_view_mode_tour(self):
|
||||
self.pos_config.self_order_table_mode = False
|
||||
self.start_tour(
|
||||
f"/menu/{self.pos_config.id}",
|
||||
"pos_self_order_tour",
|
||||
self.pos_config._get_self_order_route(),
|
||||
"pos_qr_menu_tour",
|
||||
login=None,
|
||||
)
|
||||
|
||||
def test_self_order_tour(self):
|
||||
self.pos_config.self_order_table_mode = True
|
||||
self.pos_config.self_order_pay_after = "meal"
|
||||
self.pos_config.open_ui()
|
||||
self.start_tour(
|
||||
self.pos_config._get_self_order_route(),
|
||||
"self_order_tour",
|
||||
login=None,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?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>
|
||||
@@ -18,7 +17,6 @@
|
||||
<field name="style"/>
|
||||
<field name="link_html"/>
|
||||
</tree>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -3,14 +3,28 @@
|
||||
<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="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"/>
|
||||
<xpath
|
||||
expr="//div[hasclass('dropdown-pos-config')]/div/div[hasclass('o_kanban_manage_view')]"
|
||||
position="inside">
|
||||
<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>
|
||||
<a name="preview_self_order_app"
|
||||
type="object"
|
||||
style="white-space: nowrap;"
|
||||
attrs="{'invisible': [('self_order_view_mode','=',False)]}">
|
||||
Mobile Menu
|
||||
</a>
|
||||
</div>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('o_kanban_card_header_title_name')]" position="after">
|
||||
<field name="self_order_table_mode" invisible="1" />
|
||||
<div class="badge text-bg-info o_kanban_inline_block me-2"
|
||||
attrs="{'invisible': [('self_order_table_mode','=',False)]}">
|
||||
Self Ordering Enabled
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
</odoo>
|
||||
@@ -21,19 +21,31 @@
|
||||
<template id="qr_codes_page">
|
||||
<t t-call="web.basic_layout">
|
||||
<div class="page">
|
||||
<h1 class="mb-5 ms-2">
|
||||
<h1 class="text-center">
|
||||
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 t-foreach="floors" t-as="floor">
|
||||
<!-- MDN states that "page-break-after" has been replaced by "break-after",
|
||||
but "break-after" is not supported in this version of wkhtmltopdf so we
|
||||
have to keep using "page-break-after" -->
|
||||
<div style="page-break-after: always;">
|
||||
<h3 t-if="floor.get('name')" t-out="floor['name']" class="mb-5"/>
|
||||
<div
|
||||
t-foreach="floor['rows_of_tables']"
|
||||
t-as="row_of_tables"
|
||||
class="d-flex"
|
||||
>
|
||||
<t t-foreach="row_of_tables"
|
||||
t-as="table"
|
||||
t-call="pos_self_order.qr_code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
@@ -41,15 +53,27 @@
|
||||
|
||||
<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 style="padding: 0px 40px; margin-bottom:40px; page-break-inside: avoid;">
|
||||
<!-- This page-break-inside does not seem to work;
|
||||
It would be nice to have the qr codes so the title is never on
|
||||
a different page then the qr code itself -->
|
||||
<h4 class="fw-bold text-center"
|
||||
style="margin-top: 20px"
|
||||
t-if="table['id']"
|
||||
>
|
||||
Table: <t t-esc="table['name']"/>
|
||||
</h4>
|
||||
<div class="p-3">
|
||||
<div class="mt-1 mb-4 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="first_letter_of_odoo_logo_svg">
|
||||
<svg
|
||||
class="position-absolute top-50 start-50"
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
<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="iface_orderline_notes" position="before">
|
||||
<setting string="QR Code Menu" help="Allow customers to see the menu from their phone" id="pos_self_order_view_mode">
|
||||
<setting id="iface_orderline_notes" position="after">
|
||||
<setting string="QR Code Menu" help="Allow customers to see the menu from their phone">
|
||||
<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" />
|
||||
<button class="btn btn-primary my-1 w-100" name="pos_self_order.custom_link_action" type="action" string="Configure Buttons"/>
|
||||
<button class="btn btn-primary my-1 w-100" name="generate_qr_codes_page" type="object" string="Generate Qr Codes"/>
|
||||
<button class="btn btn-primary my-1 w-100" 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">
|
||||
@@ -23,6 +23,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Self Order" help="Allow customers to order from their phones">
|
||||
<field name="pos_self_order_table_mode"/>
|
||||
<div class="content-group" attrs="{'invisible': [('pos_self_order_table_mode','=',False)]}">
|
||||
<label string="Pay after" for="pos_self_order_pay_after" class="me-2"/>
|
||||
<field name="pos_self_order_pay_after"/>
|
||||
</div>
|
||||
</setting>
|
||||
</setting>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
Reference in New Issue
Block a user