Compare commits
2 commits
5a2f5d120f
...
e625b0c2f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e625b0c2f3 | ||
|
|
f81c1ca8e7 |
13 changed files with 949 additions and 746 deletions
|
|
@ -1,5 +1,36 @@
|
|||
# Changelog - Website Sale Aplicoop
|
||||
|
||||
## [18.0.1.16.0] - 2026-08-17
|
||||
|
||||
### Changed
|
||||
|
||||
- **Payment moved into the checkout**: the separate `/eskaera/<slug>/payment`
|
||||
step is gone. Members review the summary, pick home delivery and choose a
|
||||
payment method on `/eskaera/<slug>/checkout`, in one screen. The old URL
|
||||
redirects there so bookmarks and in-flight sessions keep working.
|
||||
- **The checkout summary is server-side**: it renders the draft `sale.order`
|
||||
instead of the localStorage cart, so what is shown is what gets charged.
|
||||
"Proceed to Checkout" pushes the cart to that draft before navigating,
|
||||
reusing the cycle's draft and rewriting its lines only when they differ.
|
||||
- The home delivery checkbox on the checkout writes to the order through
|
||||
`/eskaera/set-home-delivery`, so the total and the payment amount follow it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The confirmation notice after saving a draft was destroyed by the immediate
|
||||
redirect to the payment step, so members saw nothing at all. Saving no longer
|
||||
navigates.
|
||||
- Products the member had deleted reappeared between the checkout and the
|
||||
payment page. The checkout auto-loaded the draft into localStorage after the
|
||||
summary had already rendered, resurrecting deleted lines, which were then
|
||||
sent back on confirm. The checkout no longer auto-loads the draft — it
|
||||
renders it.
|
||||
|
||||
### Removed
|
||||
|
||||
- `checkout_labels.js` and the `eskaera_checkout_summary` / `eskaera_payment`
|
||||
templates, superseded by the server-rendered summary and checkout.
|
||||
|
||||
## [18.0.1.13.0] - 2026-08-11
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
{ # noqa: B018
|
||||
"name": "Website Sale - Aplicoop",
|
||||
"version": "18.0.1.15.0",
|
||||
"version": "18.0.1.16.0",
|
||||
"category": "Website/Sale",
|
||||
"summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders",
|
||||
"author": "Odoo Community Association (OCA), Criptomart",
|
||||
|
|
@ -71,7 +71,6 @@
|
|||
"website_sale_aplicoop/static/src/js/i18n_helpers.js",
|
||||
# Core shop functionality
|
||||
"website_sale_aplicoop/static/src/js/website_sale.js",
|
||||
"website_sale_aplicoop/static/src/js/checkout_labels.js",
|
||||
"website_sale_aplicoop/static/src/js/home_delivery.js",
|
||||
"website_sale_aplicoop/static/src/js/eskaera_payment.js",
|
||||
# Search and pagination
|
||||
|
|
|
|||
|
|
@ -526,6 +526,39 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
product_max_qty[p.id] = net
|
||||
return products_ctx, product_max_qty
|
||||
|
||||
def _draft_matches_lines(self, draft, sale_order_lines, home_delivery):
|
||||
"""Whether `draft` already holds exactly `sale_order_lines`.
|
||||
|
||||
The cart is pushed to the draft on every step of the flow (saving from
|
||||
the shop, entering the checkout), so most of those writes have nothing
|
||||
to change. Rewriting `order_line` anyway would unlink and recreate
|
||||
every line for no reason — it moves their ids, bumps `write_date` and
|
||||
drops anything attached to them.
|
||||
"""
|
||||
if draft.home_delivery != home_delivery:
|
||||
return False
|
||||
if len(draft.order_line) != len(sale_order_lines):
|
||||
return False
|
||||
|
||||
currency = draft.currency_id
|
||||
|
||||
def _key(product_id, qty, price):
|
||||
return (product_id, round(qty, 3), currency.round(price))
|
||||
|
||||
current = sorted(
|
||||
_key(line.product_id.id, line.product_uom_qty, line.price_unit)
|
||||
for line in draft.order_line
|
||||
)
|
||||
incoming = sorted(
|
||||
_key(
|
||||
vals["product_id"],
|
||||
vals["product_uom_qty"],
|
||||
vals["price_unit"],
|
||||
)
|
||||
for _command, _id, vals in sale_order_lines
|
||||
)
|
||||
return current == incoming
|
||||
|
||||
def _merge_or_replace_draft(
|
||||
self,
|
||||
group_order,
|
||||
|
|
@ -555,6 +588,14 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
|
||||
if existing_drafts:
|
||||
draft = existing_drafts[0].sudo()
|
||||
if self._draft_matches_lines(
|
||||
draft, sale_order_lines, effective_home_delivery
|
||||
):
|
||||
_logger.info(
|
||||
"Draft order %s already matches the cart, left untouched",
|
||||
draft.id,
|
||||
)
|
||||
return draft
|
||||
_logger.info(
|
||||
"Replacing existing draft order %s for partner %s",
|
||||
draft.id,
|
||||
|
|
@ -1227,36 +1268,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
self._eskaera_payment_confirmation_url(group_order, placed_order)
|
||||
)
|
||||
|
||||
# Los datos del carrito vienen desde localStorage en el frontend
|
||||
# Esta página solo muestra resumen y botón de confirmación
|
||||
|
||||
# DEBUG: Log ALL delivery fields
|
||||
_logger.warning("=== ESKAERA_CHECKOUT DELIVERY DEBUG ===")
|
||||
_logger.warning("group_order.id: %s", group_order.id)
|
||||
_logger.warning("group_order.name: %s", group_order.name)
|
||||
_logger.warning(
|
||||
"group_order.pickup_day: %s (type: %s)",
|
||||
group_order.pickup_day,
|
||||
type(group_order.pickup_day),
|
||||
# The cart itself still lives in localStorage while the member shops,
|
||||
# but from here on everything is read off their draft sale.order: the
|
||||
# checkout shows the very lines and amounts that are about to be paid.
|
||||
# The shop pushes the cart to that draft before sending them here.
|
||||
order_sudo = self._find_recent_draft_order(
|
||||
request.env.user.partner_id.id, group_order
|
||||
)
|
||||
_logger.warning(
|
||||
"group_order.pickup_date: %s (type: %s)",
|
||||
group_order.pickup_date,
|
||||
type(group_order.pickup_date),
|
||||
)
|
||||
_logger.warning(
|
||||
"group_order.delivery_date: %s (type: %s)",
|
||||
group_order.delivery_date,
|
||||
type(group_order.delivery_date),
|
||||
)
|
||||
_logger.warning("group_order.home_delivery: %s", group_order.home_delivery)
|
||||
_logger.warning("group_order.delivery_notice: %s", group_order.delivery_notice)
|
||||
if group_order.pickup_date:
|
||||
_logger.warning(
|
||||
"pickup_date formatted: %s",
|
||||
group_order.pickup_date.strftime("%d/%m/%Y"),
|
||||
)
|
||||
_logger.warning("========================================")
|
||||
|
||||
# Get delivery product from group_order (configured per group order)
|
||||
delivery_product = group_order.delivery_product_id
|
||||
|
|
@ -1278,38 +1296,24 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
labels_json = json.dumps(labels, ensure_ascii=False)
|
||||
|
||||
# Online payment turns the primary button from "save a draft" into
|
||||
# "confirm and pay". Everything the template needs is resolved here so
|
||||
# it only reads attributes.
|
||||
# the payment form itself. Everything the template needs is resolved
|
||||
# here so it only reads attributes.
|
||||
online_payment = bool(group_order.online_payment)
|
||||
payment_available = online_payment and self._has_available_payment_method(
|
||||
group_order
|
||||
)
|
||||
if online_payment:
|
||||
checkout_button = {
|
||||
"label": labels.get("confirm_and_pay", "Confirm and pay"),
|
||||
"hint": labels.get(
|
||||
"confirm_and_pay_hint", "Confirm the order and go to payment"
|
||||
),
|
||||
"done_label": labels.get(
|
||||
"order_ready_for_payment", "Order ready for payment"
|
||||
),
|
||||
"icon": "fa-credit-card",
|
||||
"tooltip_key": "confirm_and_pay",
|
||||
}
|
||||
else:
|
||||
checkout_button = {
|
||||
"label": labels.get("save_draft", "Save Draft"),
|
||||
"hint": labels.get("save_order_as_draft", "Save order as draft"),
|
||||
"done_label": labels.get(
|
||||
"order_saved_as_draft", "Order saved as draft"
|
||||
),
|
||||
"icon": "fa-save",
|
||||
"tooltip_key": "save_draft",
|
||||
}
|
||||
checkout_button = {
|
||||
"label": labels.get("save_draft", "Save Draft"),
|
||||
"hint": labels.get("save_order_as_draft", "Save order as draft"),
|
||||
"done_label": labels.get("order_saved_as_draft", "Order saved as draft"),
|
||||
"icon": "fa-save",
|
||||
"tooltip_key": "save_draft",
|
||||
}
|
||||
|
||||
# Prepare template context with explicit debug info
|
||||
template_context = {
|
||||
"group_order": group_order,
|
||||
"sale_order": order_sudo,
|
||||
"day_names": self._get_day_names(env=request.env),
|
||||
"delivery_product_id": delivery_product_id,
|
||||
"delivery_product_name": delivery_product_name, # Auto-translated to user's language
|
||||
|
|
@ -1328,7 +1332,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
),
|
||||
}
|
||||
|
||||
_logger.warning("Template context keys: %s", list(template_context.keys()))
|
||||
if online_payment:
|
||||
redirect_url, payment_values = self._prepare_checkout_payment_context(
|
||||
group_order, order_sudo
|
||||
)
|
||||
if redirect_url:
|
||||
return request.redirect(redirect_url)
|
||||
template_context.update(payment_values)
|
||||
|
||||
return request.render(
|
||||
"website_sale_aplicoop.eskaera_checkout", template_context
|
||||
|
|
@ -1336,9 +1346,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
|
||||
# === Online payment ===
|
||||
|
||||
def _eskaera_payment_url(self, group_order):
|
||||
"""Return the payment step URL of `group_order`."""
|
||||
return self._eskaera_url(group_order, suffix="/payment")
|
||||
def _eskaera_checkout_url(self, group_order):
|
||||
"""Return the checkout URL of `group_order`.
|
||||
|
||||
Payment happens on the checkout page: there is no separate step, so
|
||||
the member picks a method next to the very summary they just read.
|
||||
"""
|
||||
return self._eskaera_url(group_order, suffix="/checkout")
|
||||
|
||||
def _eskaera_payment_confirmation_url(self, group_order, sale_order):
|
||||
"""Return the landing URL shown once `sale_order` has been paid."""
|
||||
|
|
@ -1401,6 +1415,44 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
)
|
||||
return values
|
||||
|
||||
def _prepare_checkout_payment_context(self, group_order, order_sudo):
|
||||
"""Resolve the payment half of the checkout page.
|
||||
|
||||
Returns `(redirect_url, values)`: a non-empty `redirect_url` means the
|
||||
member has nothing left to pay here and the caller must send them
|
||||
away instead of rendering. Otherwise `values` completes the checkout
|
||||
context — either the payment form, or the notice of a transaction
|
||||
already under way.
|
||||
"""
|
||||
if not order_sudo:
|
||||
# Nothing saved for this cycle yet: the page renders its empty
|
||||
# state and there is no amount to build a payment form around.
|
||||
return None, {"pending_transaction": False, "payment_ready": False}
|
||||
|
||||
# A transaction already under way must not be duplicated: the first one
|
||||
# to reach `done` confirms the order, so a second would be an overpay.
|
||||
last_tx = order_sudo.get_portal_last_transaction()
|
||||
if last_tx and last_tx.state in ("pending", "authorized", "done"):
|
||||
if last_tx.state == "done":
|
||||
return (
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo),
|
||||
{},
|
||||
)
|
||||
return None, {"pending_transaction": last_tx, "payment_ready": False}
|
||||
|
||||
# `_get_payment_values` prices the form as amount_total - amount_paid;
|
||||
# a non-positive amount has nothing left to charge and would silently
|
||||
# render the "no payment method" warning.
|
||||
if order_sudo.currency_id.compare_amounts(order_sudo.amount_total, 0) <= 0:
|
||||
return (
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo),
|
||||
{},
|
||||
)
|
||||
|
||||
values = self._get_eskaera_payment_values(group_order, order_sudo)
|
||||
values.update({"pending_transaction": False, "payment_ready": True})
|
||||
return None, values
|
||||
|
||||
@http.route(
|
||||
["/eskaera/<string:group_order_slug>/payment"],
|
||||
type="http",
|
||||
|
|
@ -1409,68 +1461,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
)
|
||||
@eskaera_route
|
||||
def eskaera_payment(self, group_order_slug, **post):
|
||||
"""Payment step: pick a method and pay the order placed at checkout."""
|
||||
"""Legacy payment step, folded into the checkout page.
|
||||
|
||||
Kept so bookmarks and sessions in flight when the step disappeared
|
||||
land somewhere sensible rather than on a 404.
|
||||
"""
|
||||
group_order = self._get_group_order_by_slug(group_order_slug)
|
||||
if not group_order or not group_order.online_payment:
|
||||
return request.redirect(self._eskaera_url(group_order, suffix="/checkout"))
|
||||
|
||||
# Entry gate only. Once an order exists the transaction route and the
|
||||
# landing page stay reachable even if the cycle closes meanwhile,
|
||||
# otherwise a member who is already at the provider would come back to
|
||||
# a redirect and their payment would be orphaned.
|
||||
if group_order.state != "open":
|
||||
return request.redirect("/eskaera")
|
||||
|
||||
partner = request.env.user.partner_id
|
||||
try:
|
||||
self._validate_user_group_access(group_order, request.env.user)
|
||||
except ValueError:
|
||||
return request.redirect("/eskaera")
|
||||
|
||||
placed_order = self._find_placed_cycle_order(partner.id, group_order)
|
||||
if placed_order:
|
||||
return request.redirect(
|
||||
self._eskaera_payment_confirmation_url(group_order, placed_order)
|
||||
)
|
||||
|
||||
order_sudo = self._find_recent_draft_order(partner.id, group_order)
|
||||
if not order_sudo:
|
||||
return request.redirect(self._eskaera_url(group_order, suffix="/checkout"))
|
||||
|
||||
# A transaction already under way must not be duplicated: the first one
|
||||
# to reach `done` confirms the order, so a second would be an overpay.
|
||||
last_tx = order_sudo.get_portal_last_transaction()
|
||||
if last_tx and last_tx.state in ("pending", "authorized", "done"):
|
||||
if last_tx.state == "done":
|
||||
return request.redirect(
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo)
|
||||
)
|
||||
return request.render(
|
||||
"website_sale_aplicoop.eskaera_payment",
|
||||
{
|
||||
"group_order": group_order,
|
||||
"sale_order": order_sudo,
|
||||
"pending_transaction": last_tx,
|
||||
},
|
||||
)
|
||||
|
||||
# `_get_payment_values` prices the form as amount_total - amount_paid;
|
||||
# a non-positive amount has nothing left to charge and would silently
|
||||
# render the "no payment method" warning.
|
||||
if order_sudo.currency_id.compare_amounts(order_sudo.amount_total, 0) <= 0:
|
||||
return request.redirect(
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo)
|
||||
)
|
||||
|
||||
values = self._get_eskaera_payment_values(group_order, order_sudo)
|
||||
values.update(
|
||||
{
|
||||
"group_order": group_order,
|
||||
"sale_order": order_sudo,
|
||||
"pending_transaction": False,
|
||||
}
|
||||
)
|
||||
return request.render("website_sale_aplicoop.eskaera_payment", values)
|
||||
return request.redirect(self._eskaera_checkout_url(group_order))
|
||||
|
||||
@http.route(
|
||||
["/eskaera/<string:group_order_slug>/payment/confirmation/<int:order_id>"],
|
||||
|
|
@ -1969,12 +1966,12 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
"sale_order_id": sale_order.id,
|
||||
"pickup_slot_label": pickup_slot_label,
|
||||
}
|
||||
# With online payment on, saving the cart is only the first half of
|
||||
# placing the order: the frontend follows this URL to the payment
|
||||
# step. The server builds it so the client never assembles routes.
|
||||
# No redirect here. Saving used to bounce the member to a separate
|
||||
# payment step, which also killed the confirmation notice on the
|
||||
# way out; payment now lives on the checkout page, and the caller
|
||||
# decides whether it is staying or moving on.
|
||||
if group_order.online_payment:
|
||||
response_data["message"] = request.env._("Order ready for payment")
|
||||
response_data["redirect_url"] = self._eskaera_payment_url(group_order)
|
||||
|
||||
return request.make_response(
|
||||
json.dumps(response_data),
|
||||
|
|
@ -1992,6 +1989,150 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
status=500,
|
||||
)
|
||||
|
||||
@http.route(
|
||||
["/eskaera/set-home-delivery"],
|
||||
type="http",
|
||||
auth="user",
|
||||
website=True,
|
||||
methods=["POST"],
|
||||
csrf=False,
|
||||
)
|
||||
@eskaera_route
|
||||
def set_eskaera_home_delivery(self, **post):
|
||||
"""Move the delivery line on and off the cycle draft.
|
||||
|
||||
On the shop the delivery product is just another cart entry, but the
|
||||
checkout renders the sale.order, and its total is what the payment
|
||||
form charges. So the toggle has to reach the order itself; the client
|
||||
only says yes or no.
|
||||
"""
|
||||
try:
|
||||
data = self._decode_json_body()
|
||||
|
||||
order_id = data.get("order_id")
|
||||
try:
|
||||
order_id = int(order_id)
|
||||
except (TypeError, ValueError):
|
||||
return request.make_response(
|
||||
json.dumps({"error": "order_id is required"}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=400,
|
||||
)
|
||||
|
||||
group_order = request.env["group.order"].sudo().browse(order_id).exists()
|
||||
if not group_order:
|
||||
return request.make_response(
|
||||
json.dumps({"error": f"Order {order_id} not found"}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=400,
|
||||
)
|
||||
if group_order.state != "open":
|
||||
return self._build_group_order_unavailable_response(group_order)
|
||||
|
||||
current_user = request.env.user
|
||||
try:
|
||||
self._validate_user_group_access(group_order, current_user)
|
||||
except ValueError as e:
|
||||
return request.make_response(
|
||||
json.dumps({"error": str(e)}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=403,
|
||||
)
|
||||
|
||||
if placed_response := self._build_already_placed_response(
|
||||
current_user.partner_id.id, group_order
|
||||
):
|
||||
return placed_response
|
||||
|
||||
draft = self._find_recent_draft_order(
|
||||
current_user.partner_id.id, group_order
|
||||
)
|
||||
if not draft:
|
||||
return request.make_response(
|
||||
json.dumps(
|
||||
{
|
||||
"error": request.env._(
|
||||
"No draft orders found for the current order period"
|
||||
)
|
||||
}
|
||||
),
|
||||
[("Content-Type", "application/json")],
|
||||
status=404,
|
||||
)
|
||||
draft = draft[0].sudo()
|
||||
|
||||
is_delivery = self._to_bool(data.get("is_delivery", False))
|
||||
effective_delivery, commitment_date = self._get_effective_delivery_context(
|
||||
group_order, is_delivery
|
||||
)
|
||||
delivery_product = group_order.delivery_product_id
|
||||
|
||||
existing_lines = draft.order_line.filtered(
|
||||
lambda line: line.product_id == delivery_product
|
||||
)
|
||||
if effective_delivery and delivery_product:
|
||||
if not existing_lines:
|
||||
pricing = self._get_pricing_info(
|
||||
delivery_product, self._resolve_pricelist(), quantity=1.0
|
||||
)
|
||||
draft.write(
|
||||
{
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": delivery_product.id,
|
||||
"product_uom_qty": 1.0,
|
||||
"price_unit": pricing.get(
|
||||
"price_unit", delivery_product.list_price
|
||||
),
|
||||
"name": delivery_product.with_context(
|
||||
lang=request.env.lang
|
||||
).name,
|
||||
},
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
elif existing_lines:
|
||||
existing_lines.unlink()
|
||||
|
||||
draft.write(
|
||||
{
|
||||
"home_delivery": effective_delivery,
|
||||
"commitment_date": commitment_date,
|
||||
}
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"set_eskaera_home_delivery: order %s home_delivery=%s",
|
||||
draft.id,
|
||||
effective_delivery,
|
||||
)
|
||||
|
||||
return request.make_response(
|
||||
json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"is_delivery": effective_delivery,
|
||||
"sale_order_id": draft.id,
|
||||
"delivery_product_id": (
|
||||
delivery_product.id if delivery_product else None
|
||||
),
|
||||
}
|
||||
),
|
||||
[("Content-Type", "application/json")],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception("set_eskaera_home_delivery: Unexpected error")
|
||||
return request.make_response(
|
||||
json.dumps({"error": str(e)}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=500,
|
||||
)
|
||||
|
||||
@http.route(
|
||||
["/eskaera/confirm"],
|
||||
type="http",
|
||||
|
|
|
|||
|
|
@ -73,9 +73,10 @@ confirms in bulk.
|
|||
offer it everywhere, or set it to restrict the provider to one site.
|
||||
#. Open the group order form → *Online Payment* tab → tick **Online payment**.
|
||||
|
||||
With the flag on, the checkout button becomes "Confirm and pay" and paying is
|
||||
the only way to place an order: the member goes through
|
||||
``/eskaera/<slug>/payment``, picks a method, and their sale order is confirmed
|
||||
as soon as the transaction completes. Members who have not paid by the cutoff
|
||||
With the flag on, paying is the only way to place an order: the payment
|
||||
methods appear on ``/eskaera/<slug>/checkout``, next to the order summary, and
|
||||
the member's sale order is confirmed as soon as the transaction completes.
|
||||
There is no separate payment step — the old ``/eskaera/<slug>/payment`` URL
|
||||
redirects to the checkout. Members who have not paid by the cutoff
|
||||
date still get their draft confirmed by the cron, exactly as they do today —
|
||||
the flag decides how orders are placed, not who gets served.
|
||||
|
|
|
|||
|
|
@ -50,9 +50,11 @@ Paying an order online
|
|||
When the group order has online payment enabled:
|
||||
|
||||
#. Build the cart in ``/eskaera/<slug>`` as usual
|
||||
#. Go to the checkout, review the summary and choose home delivery if offered
|
||||
#. Press **Confirm and pay**: the order is saved and the payment step opens
|
||||
#. Pick a payment method and pay; you are sent to the provider and back
|
||||
#. Press **Proceed to Checkout**: the cart is saved to your order first, so
|
||||
the checkout shows the very lines and amounts that will be charged
|
||||
#. Review the summary, choose home delivery if offered, and pick a payment
|
||||
method — all on the same page
|
||||
#. Press **Confirm and pay**; you are sent to the provider and back
|
||||
#. The confirmation page shows the outcome and empties the local cart
|
||||
|
||||
The order is confirmed the moment the payment goes through, so it can no
|
||||
|
|
|
|||
|
|
@ -1,340 +0,0 @@
|
|||
/**
|
||||
* Checkout Labels Loading
|
||||
* Fetches translated labels for checkout table summary
|
||||
* IMPORTANT: This script waits for the cart to be loaded by website_sale.js
|
||||
* before rendering the checkout summary.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
console.log("[CHECKOUT] Script loaded");
|
||||
|
||||
// Get order ID from button
|
||||
var confirmBtn = document.getElementById("confirm-order-btn");
|
||||
if (!confirmBtn) {
|
||||
console.log("[CHECKOUT] No confirm button found");
|
||||
return;
|
||||
}
|
||||
|
||||
var orderId = confirmBtn.getAttribute("data-order-id");
|
||||
if (!orderId) {
|
||||
console.log("[CHECKOUT] No order ID found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[CHECKOUT] Order ID:", orderId);
|
||||
|
||||
// Get summary div
|
||||
var summaryDiv = document.getElementById("checkout-summary");
|
||||
if (!summaryDiv) {
|
||||
console.log("[CHECKOUT] No summary div found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Function to fetch labels and render checkout
|
||||
var fetchLabelsAndRender = function () {
|
||||
console.log("[CHECKOUT] Fetching labels...");
|
||||
|
||||
// Wait for window.groupOrderShop.labels to be initialized (contains hardcoded labels)
|
||||
var waitForLabels = function (callback, maxWait = 3000, checkInterval = 50) {
|
||||
var startTime = Date.now();
|
||||
var checkLabels = function () {
|
||||
if (
|
||||
window.groupOrderShop &&
|
||||
window.groupOrderShop.labels &&
|
||||
Object.keys(window.groupOrderShop.labels).length > 0
|
||||
) {
|
||||
console.log("[CHECKOUT] ✅ Hardcoded labels found, proceeding");
|
||||
callback();
|
||||
} else if (Date.now() - startTime < maxWait) {
|
||||
setTimeout(checkLabels, checkInterval);
|
||||
} else {
|
||||
console.log("[CHECKOUT] ⚠️ Timeout waiting for labels, proceeding anyway");
|
||||
callback();
|
||||
}
|
||||
};
|
||||
checkLabels();
|
||||
};
|
||||
|
||||
waitForLabels(function () {
|
||||
// Now fetch additional labels from server
|
||||
// Detect current language from document or navigator
|
||||
var currentLang =
|
||||
document.documentElement.lang ||
|
||||
document.documentElement.getAttribute("lang") ||
|
||||
navigator.language ||
|
||||
"es_ES";
|
||||
console.log("[CHECKOUT] Detected language:", currentLang);
|
||||
|
||||
fetch("/eskaera/labels", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
lang: currentLang,
|
||||
}),
|
||||
})
|
||||
.then(function (response) {
|
||||
console.log("[CHECKOUT] Response status:", response.status);
|
||||
return response.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
console.log("[CHECKOUT] Response data:", data);
|
||||
var serverLabels = data.result || data;
|
||||
console.log(
|
||||
"[CHECKOUT] Server labels count:",
|
||||
Object.keys(serverLabels).length
|
||||
);
|
||||
console.log("[CHECKOUT] Sample server labels:", {
|
||||
draft_merged_success: serverLabels.draft_merged_success,
|
||||
home_delivery: serverLabels.home_delivery,
|
||||
});
|
||||
|
||||
// CRITICAL: Merge server labels with existing hardcoded labels
|
||||
// Hardcoded labels MUST take precedence over server labels
|
||||
if (window.groupOrderShop && window.groupOrderShop.labels) {
|
||||
var existingLabels = window.groupOrderShop.labels;
|
||||
console.log(
|
||||
"[CHECKOUT] Existing hardcoded labels count:",
|
||||
Object.keys(existingLabels).length
|
||||
);
|
||||
console.log("[CHECKOUT] Sample existing labels:", {
|
||||
draft_merged_success: existingLabels.draft_merged_success,
|
||||
home_delivery: existingLabels.home_delivery,
|
||||
});
|
||||
|
||||
// Start with server labels, then overwrite with hardcoded ones
|
||||
var mergedLabels = Object.assign({}, serverLabels);
|
||||
Object.assign(mergedLabels, existingLabels);
|
||||
|
||||
window.groupOrderShop.labels = mergedLabels;
|
||||
console.log(
|
||||
"[CHECKOUT] ✅ Merged labels - final count:",
|
||||
Object.keys(mergedLabels).length
|
||||
);
|
||||
console.log("[CHECKOUT] Verification:", {
|
||||
draft_merged_success: mergedLabels.draft_merged_success,
|
||||
home_delivery: mergedLabels.home_delivery,
|
||||
});
|
||||
} else {
|
||||
// If no existing labels, use server labels as fallback
|
||||
if (window.groupOrderShop) {
|
||||
window.groupOrderShop.labels = serverLabels;
|
||||
}
|
||||
console.log("[CHECKOUT] ⚠️ No existing labels, using server labels");
|
||||
}
|
||||
|
||||
window.renderCheckoutSummary(window.groupOrderShop.labels);
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.error("[CHECKOUT] Error:", error);
|
||||
// Fallback to translated labels
|
||||
window.renderCheckoutSummary(window.getCheckoutLabels());
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Listen for cart ready event instead of polling
|
||||
if (window.groupOrderShop && window.groupOrderShop.orderId) {
|
||||
// Cart already initialized, render immediately
|
||||
console.log("[CHECKOUT] Cart already ready");
|
||||
fetchLabelsAndRender();
|
||||
} else {
|
||||
// Wait for cart initialization event
|
||||
console.log("[CHECKOUT] Waiting for cart ready event...");
|
||||
document.addEventListener(
|
||||
"groupOrderCartReady",
|
||||
function () {
|
||||
console.log("[CHECKOUT] Cart ready event received");
|
||||
fetchLabelsAndRender();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
// Fallback timeout in case event never fires
|
||||
setTimeout(function () {
|
||||
if (window.groupOrderShop && window.groupOrderShop.orderId) {
|
||||
console.log("[CHECKOUT] Fallback timeout triggered");
|
||||
fetchLabelsAndRender();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render order summary table or empty message
|
||||
* Exposed globally so other scripts can call it
|
||||
*/
|
||||
window.renderCheckoutSummary = function (labels) {
|
||||
labels = labels || window.getCheckoutLabels();
|
||||
|
||||
var summaryDiv = document.getElementById("checkout-summary");
|
||||
if (!summaryDiv) return;
|
||||
|
||||
var cartKey =
|
||||
"eskaera_" +
|
||||
(document.getElementById("confirm-order-btn")
|
||||
? document.getElementById("confirm-order-btn").getAttribute("data-order-id")
|
||||
: "1") +
|
||||
"_cart";
|
||||
var cart = JSON.parse(localStorage.getItem(cartKey) || "{}");
|
||||
|
||||
var summaryTable = summaryDiv.querySelector(".checkout-summary-table");
|
||||
var tbody = summaryDiv.querySelector("#checkout-summary-tbody");
|
||||
var totalSection = summaryDiv.querySelector(".checkout-total-section");
|
||||
|
||||
// If no table found, create it with headers (shouldn't happen, but fallback)
|
||||
if (!summaryTable) {
|
||||
var html =
|
||||
'<table class="table table-hover checkout-summary-table" id="checkout-summary-table" role="grid" aria-label="Purchase summary"><thead class="table-dark"><tr>' +
|
||||
'<th scope="col" class="col-name">' +
|
||||
escapeHtml(labels.product) +
|
||||
"</th>" +
|
||||
'<th scope="col" class="col-qty text-center">' +
|
||||
escapeHtml(labels.quantity) +
|
||||
"</th>" +
|
||||
'<th scope="col" class="col-price text-end">' +
|
||||
escapeHtml(labels.price) +
|
||||
"</th>" +
|
||||
'<th scope="col" class="col-subtotal text-end">' +
|
||||
escapeHtml(labels.subtotal) +
|
||||
"</th>" +
|
||||
'</tr></thead><tbody id="checkout-summary-tbody"></tbody></table>' +
|
||||
'<div class="checkout-total-section"><div class="total-row">' +
|
||||
'<span class="total-label">' +
|
||||
escapeHtml(labels.total) +
|
||||
"</span>" +
|
||||
'<span class="total-amount" id="checkout-total-amount">€0.00</span>' +
|
||||
"</div></div>";
|
||||
summaryDiv.innerHTML = html;
|
||||
summaryTable = summaryDiv.querySelector(".checkout-summary-table");
|
||||
tbody = summaryDiv.querySelector("#checkout-summary-tbody");
|
||||
totalSection = summaryDiv.querySelector(".checkout-total-section");
|
||||
}
|
||||
|
||||
// Clear only tbody, preserve headers
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (Object.keys(cart).length === 0) {
|
||||
// Show empty message if cart is empty
|
||||
var emptyRow = document.createElement("tr");
|
||||
emptyRow.id = "checkout-empty-row";
|
||||
emptyRow.className = "empty-message";
|
||||
emptyRow.innerHTML =
|
||||
'<td colspan="4" class="text-center text-muted py-4">' +
|
||||
'<i class="fa fa-inbox fa-2x mb-2"></i>' +
|
||||
"<p>" +
|
||||
escapeHtml(labels.empty) +
|
||||
"</p>" +
|
||||
"</td>";
|
||||
tbody.appendChild(emptyRow);
|
||||
|
||||
// Hide total section
|
||||
totalSection.style.display = "none";
|
||||
} else {
|
||||
// Hide empty row if visible
|
||||
var emptyRow = tbody.querySelector("#checkout-empty-row");
|
||||
if (emptyRow) emptyRow.remove();
|
||||
|
||||
// Get delivery product ID from page data
|
||||
var checkoutPage = document.querySelector(".eskaera-checkout-page");
|
||||
var deliveryProductId = checkoutPage
|
||||
? checkoutPage.getAttribute("data-delivery-product-id")
|
||||
: null;
|
||||
|
||||
// Separate normal products from delivery product
|
||||
var normalProducts = [];
|
||||
var deliveryProduct = null;
|
||||
|
||||
Object.keys(cart).forEach(function (productId) {
|
||||
if (productId === deliveryProductId) {
|
||||
deliveryProduct = { id: productId, item: cart[productId] };
|
||||
} else {
|
||||
normalProducts.push({ id: productId, item: cart[productId] });
|
||||
}
|
||||
});
|
||||
|
||||
// Sort normal products numerically
|
||||
normalProducts.sort(function (a, b) {
|
||||
return parseInt(a.id) - parseInt(b.id);
|
||||
});
|
||||
|
||||
var total = 0;
|
||||
|
||||
// Render normal products first
|
||||
normalProducts.forEach(function (product) {
|
||||
var item = product.item;
|
||||
var qty = parseFloat(item.quantity || item.qty || 1);
|
||||
if (isNaN(qty)) qty = 1;
|
||||
var price = parseFloat(item.price || 0);
|
||||
if (isNaN(price)) price = 0;
|
||||
var subtotal = qty * price;
|
||||
total += subtotal;
|
||||
|
||||
var row = document.createElement("tr");
|
||||
row.innerHTML =
|
||||
"<td>" +
|
||||
escapeHtml(item.name) +
|
||||
"</td>" +
|
||||
'<td class="text-center">' +
|
||||
qty.toFixed(2).replace(/\.?0+$/, "") +
|
||||
"</td>" +
|
||||
'<td class="text-end">€' +
|
||||
price.toFixed(2) +
|
||||
"</td>" +
|
||||
'<td class="text-end">€' +
|
||||
subtotal.toFixed(2) +
|
||||
"</td>";
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// Render delivery product last if present
|
||||
if (deliveryProduct) {
|
||||
var item = deliveryProduct.item;
|
||||
var qty = parseFloat(item.quantity || item.qty || 1);
|
||||
if (isNaN(qty)) qty = 1;
|
||||
var price = parseFloat(item.price || 0);
|
||||
if (isNaN(price)) price = 0;
|
||||
var subtotal = qty * price;
|
||||
total += subtotal;
|
||||
|
||||
var row = document.createElement("tr");
|
||||
row.innerHTML =
|
||||
"<td>" +
|
||||
escapeHtml(item.name) +
|
||||
"</td>" +
|
||||
'<td class="text-center">' +
|
||||
qty.toFixed(2).replace(/\.?0+$/, "") +
|
||||
"</td>" +
|
||||
'<td class="text-end">€' +
|
||||
price.toFixed(2) +
|
||||
"</td>" +
|
||||
'<td class="text-end">€' +
|
||||
subtotal.toFixed(2) +
|
||||
"</td>";
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
|
||||
// Update total
|
||||
var totalAmount = summaryDiv.querySelector("#checkout-total-amount");
|
||||
if (totalAmount) {
|
||||
totalAmount.textContent = "€" + total.toFixed(2);
|
||||
}
|
||||
|
||||
// Show total section
|
||||
totalSection.style.display = "block";
|
||||
}
|
||||
|
||||
console.log("[CHECKOUT] Summary rendered");
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
})();
|
||||
|
|
@ -68,10 +68,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Get order ID from multiple possible sources
|
||||
// Get order ID from multiple possible sources. The checkout page
|
||||
// carries it on its wrapper: with online payment on there is no
|
||||
// confirm button there, the payment form takes its place.
|
||||
var confirmBtn = document.getElementById("confirm-order-btn");
|
||||
var cartContainer = document.getElementById("cart-items-container");
|
||||
var orderIdElement = confirmBtn || cartContainer;
|
||||
var orderIdElement =
|
||||
confirmBtn ||
|
||||
cartContainer ||
|
||||
(checkoutPage && checkoutPage.getAttribute("data-order-id") ? checkoutPage : null);
|
||||
|
||||
// The URL is not a fallback here: it carries the slug of the order,
|
||||
// not its id.
|
||||
|
|
@ -81,22 +86,16 @@
|
|||
|
||||
console.log("[HomeDelivery] orderId resolved:", this.orderId);
|
||||
|
||||
// Handle checkbox (only exists on checkout page)
|
||||
// Handle checkbox (only exists on checkout page). Its state is
|
||||
// rendered from `sale_order.home_delivery`, so it is not read back
|
||||
// from localStorage here: the order is what the payment form
|
||||
// charges, and the two must not disagree.
|
||||
var checkbox = document.getElementById("home-delivery-checkbox");
|
||||
if (checkbox) {
|
||||
var self = this;
|
||||
checkbox.addEventListener("change", function () {
|
||||
if (this.checked) {
|
||||
self.addDeliveryProduct();
|
||||
self.showDeliveryInfo();
|
||||
} else {
|
||||
self.removeDeliveryProduct();
|
||||
self.hideDeliveryInfo();
|
||||
}
|
||||
self.setDeliveryOnOrder(this.checked, this);
|
||||
});
|
||||
|
||||
// Check if delivery product is already in cart on page load
|
||||
this.checkDeliveryInCart();
|
||||
}
|
||||
|
||||
// Vincular botón Home Delivery en el shop SOLO si hay un producto de delivery válido
|
||||
|
|
@ -133,11 +132,6 @@
|
|||
homeDeliveryBtn.classList.remove("btn-outline-warning");
|
||||
homeDeliveryBtn.classList.add("active", "btn-warning");
|
||||
}
|
||||
|
||||
// Trigger cart reload to update UI
|
||||
if (typeof window.renderCheckoutSummary === "function") {
|
||||
window.renderCheckoutSummary();
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial button state
|
||||
|
|
@ -184,17 +178,92 @@
|
|||
}
|
||||
},
|
||||
|
||||
checkDeliveryInCart: function () {
|
||||
if (!this.deliveryProductId) return;
|
||||
/**
|
||||
* Move the delivery line on and off the draft order (checkout page).
|
||||
*
|
||||
* The checkout renders the sale.order and its total is what gets
|
||||
* charged, so the toggle has to reach the order — writing only to
|
||||
* localStorage would change the summary and leave the amount alone.
|
||||
* localStorage is kept in step so the shop cart agrees, and the page
|
||||
* is reloaded to pick up the new total and payment amount.
|
||||
*/
|
||||
setDeliveryOnOrder: function (isDelivery, checkbox) {
|
||||
var self = this;
|
||||
|
||||
var cart = this.getCart();
|
||||
if (cart[this.deliveryProductId]) {
|
||||
var checkbox = document.getElementById("home-delivery-checkbox");
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
this.showDeliveryInfo();
|
||||
}
|
||||
if (!this.orderId) {
|
||||
console.warn("[HomeDelivery] No order id, cannot set delivery");
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkbox) {
|
||||
checkbox.disabled = true;
|
||||
}
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/eskaera/set-home-delivery", true);
|
||||
xhr.setRequestHeader("Content-Type", "application/json");
|
||||
|
||||
xhr.onload = function () {
|
||||
if (xhr.status !== 200) {
|
||||
console.error("[HomeDelivery] set-home-delivery failed:", xhr.status);
|
||||
self.revertCheckbox(checkbox, !isDelivery);
|
||||
return;
|
||||
}
|
||||
var data = {};
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
data = {};
|
||||
}
|
||||
if (!data.success) {
|
||||
console.error("[HomeDelivery] set-home-delivery rejected:", data.error);
|
||||
self.revertCheckbox(checkbox, !isDelivery);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the shop cart in step with the order before reloading.
|
||||
var cart = self.getCart();
|
||||
if (self.deliveryProductId) {
|
||||
if (data.is_delivery) {
|
||||
cart[self.deliveryProductId] = {
|
||||
id: self.deliveryProductId,
|
||||
name: self.deliveryProductName,
|
||||
price: self.deliveryProductPrice,
|
||||
qty: 1,
|
||||
};
|
||||
} else {
|
||||
delete cart[self.deliveryProductId];
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"eskaera_" + self.orderId + "_cart",
|
||||
JSON.stringify(cart)
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("[HomeDelivery] Could not update the local cart:", e);
|
||||
}
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
console.error("[HomeDelivery] set-home-delivery connection error");
|
||||
self.revertCheckbox(checkbox, !isDelivery);
|
||||
};
|
||||
|
||||
xhr.send(
|
||||
JSON.stringify({
|
||||
order_id: self.orderId,
|
||||
is_delivery: isDelivery,
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
revertCheckbox: function (checkbox, previousState) {
|
||||
if (!checkbox) return;
|
||||
checkbox.checked = previousState;
|
||||
checkbox.disabled = false;
|
||||
},
|
||||
|
||||
getCart: function () {
|
||||
|
|
@ -216,14 +285,6 @@
|
|||
window.groupOrderShop._updateCartDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render checkout summary without reloading
|
||||
setTimeout(function () {
|
||||
// Use the global function from checkout_labels.js
|
||||
if (typeof window.renderCheckoutSummary === "function") {
|
||||
window.renderCheckoutSummary();
|
||||
}
|
||||
}, 50);
|
||||
},
|
||||
|
||||
addDeliveryProduct: function () {
|
||||
|
|
|
|||
|
|
@ -20,8 +20,12 @@
|
|||
// Get order ID first (needed by i18nManager and other functions)
|
||||
var confirmBtn = document.getElementById("confirm-order-btn");
|
||||
var cartContainer = document.getElementById("cart-items-container");
|
||||
// The checkout page carries the id on its wrapper: with online
|
||||
// payment on there is no confirm button there, the payment form
|
||||
// takes its place.
|
||||
this._checkoutPage = document.querySelector(".eskaera-checkout-page[data-order-id]");
|
||||
|
||||
var orderIdElement = confirmBtn || cartContainer;
|
||||
var orderIdElement = confirmBtn || cartContainer || this._checkoutPage;
|
||||
if (!orderIdElement) {
|
||||
console.log("No elements found to get order ID");
|
||||
return false;
|
||||
|
|
@ -126,6 +130,15 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Never on the checkout page. It renders the draft server-side, so
|
||||
// pulling the same lines back into localStorage would only put the
|
||||
// two out of step — and it used to resurrect lines the member had
|
||||
// just deleted in the shop, which then reappeared at payment time.
|
||||
if (this._checkoutPage) {
|
||||
console.log("Auto-load draft skipped (checkout renders the order itself)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only auto-load if cart is empty
|
||||
var cartItemsCount = Object.keys(this.cart).length;
|
||||
if (cartItemsCount > 0) {
|
||||
|
|
@ -442,8 +455,7 @@
|
|||
// Storage layout:
|
||||
// eskaera_<id>_cart → items as plain {productId: {...}}
|
||||
// (keeps the contract used by
|
||||
// checkout_labels.js, home_delivery.js
|
||||
// and _saveOrderDraft).
|
||||
// home_delivery.js and _saveOrderDraft).
|
||||
// eskaera_<id>_cart_cycle → "YYYY-MM-DD" the cart was saved at.
|
||||
// Eviction only fires when we KNOW the current cycle and it
|
||||
// disagrees with the stored one. If either side is missing
|
||||
|
|
@ -472,8 +484,8 @@
|
|||
return;
|
||||
}
|
||||
// Migration: v18.0.1.10.0 wrapped the cart as {cutoff_date, items}
|
||||
// in the same key, which broke checkout_labels.js / home_delivery.js
|
||||
// / _saveOrderDraft (they iterated Object.keys treating them as
|
||||
// in the same key, which broke home_delivery.js and
|
||||
// _saveOrderDraft (they iterated Object.keys treating them as
|
||||
// productIds). Unwrap once; subsequent saves use the canonical
|
||||
// split-key layout below.
|
||||
var items;
|
||||
|
|
@ -747,7 +759,8 @@
|
|||
},
|
||||
|
||||
_getLabels: function () {
|
||||
// Get current labels from window.groupOrderShop which is updated by checkout_labels.js
|
||||
// Get current labels from window.groupOrderShop, seeded by the
|
||||
// labels the page renders inline.
|
||||
console.log("[_getLabels] Starting label resolution...");
|
||||
console.log("[_getLabels] window.groupOrderShop exists:", !!window.groupOrderShop);
|
||||
|
||||
|
|
@ -880,7 +893,7 @@
|
|||
|
||||
_showConfirmation: function (message, onConfirm, onCancel) {
|
||||
var self = this;
|
||||
// Get current labels - may be updated by checkout_labels.js endpoint
|
||||
// Get current labels - seeded by the labels the page renders inline
|
||||
var labels = this._getLabels();
|
||||
console.log("[_showConfirmation] Using labels:", labels);
|
||||
|
||||
|
|
@ -998,29 +1011,15 @@
|
|||
});
|
||||
}
|
||||
|
||||
// On checkout page: apply sessionStorage delivery preference to checkbox.
|
||||
// The shop-page toggle may have stored a "false" preference even though
|
||||
// the checkbox is checked by default in the template.
|
||||
var checkoutCheckbox = document.getElementById("home-delivery-checkbox");
|
||||
if (checkoutCheckbox) {
|
||||
var storedDeliveryPref = sessionStorage.getItem(
|
||||
"eskaera_is_delivery_" + self.orderId
|
||||
);
|
||||
if (storedDeliveryPref !== null) {
|
||||
checkoutCheckbox.checked = storedDeliveryPref === "true";
|
||||
console.log(
|
||||
"[CHECKOUT] Restored delivery checkbox from sessionStorage:",
|
||||
checkoutCheckbox.checked
|
||||
);
|
||||
}
|
||||
// Sync sessionStorage when user manually changes the checkbox
|
||||
checkoutCheckbox.addEventListener("change", function () {
|
||||
sessionStorage.setItem(
|
||||
"eskaera_is_delivery_" + self.orderId,
|
||||
this.checked ? "true" : "false"
|
||||
);
|
||||
});
|
||||
}
|
||||
// The checkout delivery checkbox is no longer restored from
|
||||
// sessionStorage: it renders from `sale_order.home_delivery`,
|
||||
// and home_delivery.js writes any change straight back to the
|
||||
// order. A stale session preference could only contradict it.
|
||||
|
||||
// Send the cart to the server before opening the checkout, so
|
||||
// the page renders the member's current cart rather than
|
||||
// whatever the draft happened to hold.
|
||||
this._attachCheckoutLinkListeners();
|
||||
|
||||
// Button to reload from draft (in My Cart header - cart pages)
|
||||
var reloadCartBtn = document.getElementById("reload-cart-btn");
|
||||
|
|
@ -1621,6 +1620,103 @@
|
|||
self._executeSaveCartAsDraft(items);
|
||||
},
|
||||
|
||||
// Read the cart the way the server wants it. localStorage is the
|
||||
// source of truth while shopping: home_delivery.js writes to it
|
||||
// directly, so this.cart can lag behind by a tick.
|
||||
_collectCartItems: function () {
|
||||
var cartKey = "eskaera_" + this.orderId + "_cart";
|
||||
var storedCart = localStorage.getItem(cartKey);
|
||||
var cart;
|
||||
try {
|
||||
cart = storedCart ? JSON.parse(storedCart) : this.cart;
|
||||
} catch (e) {
|
||||
cart = this.cart;
|
||||
}
|
||||
return Object.keys(cart || {}).map(function (productId) {
|
||||
var item = cart[productId];
|
||||
return {
|
||||
product_id: productId,
|
||||
product_name: item.name,
|
||||
quantity: item.qty,
|
||||
product_price: item.price,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// The checkout renders the draft sale.order, so the cart has to reach
|
||||
// the server before we navigate. Saving is idempotent: the endpoint
|
||||
// reuses the cycle's draft and only rewrites its lines when they
|
||||
// actually differ, so a member who already saved from the shop just
|
||||
// gets their changes applied.
|
||||
_attachCheckoutLinkListeners: function () {
|
||||
var self = this;
|
||||
var links = document.querySelectorAll(".js-eskaera-checkout");
|
||||
console.log("[_attachEventListeners] checkout links found:", links.length);
|
||||
|
||||
links.forEach(function (link) {
|
||||
link.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
self._saveCartAndGoToCheckout(link.getAttribute("href"));
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_saveCartAndGoToCheckout: function (checkoutUrl) {
|
||||
var self = this;
|
||||
var labels = this._getLabels();
|
||||
var items = this._collectCartItems();
|
||||
|
||||
if (items.length === 0) {
|
||||
this._showNotification(labels.empty_cart || "Your cart is empty", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
var orderData = {
|
||||
order_id: this.orderId,
|
||||
items: items,
|
||||
merge_action: "replace",
|
||||
};
|
||||
|
||||
// Delivery preference: the shop toggle owns it on this page.
|
||||
var deliveryBtn = document.getElementById("home-delivery-btn");
|
||||
if (deliveryBtn) {
|
||||
orderData.is_delivery = deliveryBtn.classList.contains("active");
|
||||
}
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/eskaera/save-order", true);
|
||||
xhr.setRequestHeader("Content-Type", "application/json");
|
||||
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
window.location.href = checkoutUrl;
|
||||
return;
|
||||
}
|
||||
if (self._isClosedOrderResponse(xhr)) {
|
||||
self._clearCurrentOrderCartSilently();
|
||||
self._updateCartDisplay();
|
||||
return;
|
||||
}
|
||||
if (self._handleAlreadyPlacedResponse(xhr)) {
|
||||
return;
|
||||
}
|
||||
var message = labels.error_saving_draft || "Error saving cart";
|
||||
try {
|
||||
var errorData = JSON.parse(xhr.responseText);
|
||||
message = errorData.error || message;
|
||||
} catch (e) {
|
||||
message = message + " (HTTP " + xhr.status + ")";
|
||||
}
|
||||
self._showNotification(message, "danger");
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
self._showNotification(labels.connection_error || "Connection error", "danger");
|
||||
};
|
||||
|
||||
xhr.send(JSON.stringify(orderData));
|
||||
},
|
||||
|
||||
_executeSaveCartAsDraft: function (items) {
|
||||
var self = this;
|
||||
|
||||
|
|
@ -1934,13 +2030,10 @@
|
|||
labels.draft_saved_success ||
|
||||
labels.draft_saved ||
|
||||
"Order saved as draft successfully";
|
||||
// No navigation here: the confirmation notice used
|
||||
// to be wiped out by an immediate redirect to the
|
||||
// payment step, so the member saw nothing at all.
|
||||
self._showNotification("\u2713 " + successMsg, "success", 5000);
|
||||
// With online payment on, the server answers with the
|
||||
// payment step URL: saving the cart is only half of
|
||||
// placing the order.
|
||||
if (data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
}
|
||||
} else {
|
||||
self._showNotification(
|
||||
"Error: " + (data.error || labels.error_unknown || "Unknown error"),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
|
@ -471,22 +472,25 @@ class TestOnlinePaymentRoutes(HttpCase):
|
|||
def _slug_url(self, suffix=""):
|
||||
return f"/eskaera/{self.group_order.slug}{suffix}"
|
||||
|
||||
def test_payment_page_renders(self):
|
||||
"""The payment step renders for a member with a draft in the cycle."""
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/payment"), allow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
'data-name="Eskaera Payment"',
|
||||
response.text,
|
||||
"The payment step should render its own page, not redirect away",
|
||||
def _publish_a_provider(self):
|
||||
"""Make one provider usable, so the checkout renders the payment form."""
|
||||
provider = self.env.ref("payment.payment_provider_transfer")
|
||||
provider.write(
|
||||
{
|
||||
"state": "test",
|
||||
"is_published": True,
|
||||
"company_id": self.group_order.company_id.id,
|
||||
}
|
||||
)
|
||||
return provider
|
||||
|
||||
def test_payment_page_needs_a_draft(self):
|
||||
"""With nothing in the cart there is nothing to pay for."""
|
||||
def test_legacy_payment_step_redirects_to_checkout(self):
|
||||
"""The separate payment step is gone; its URL lands on the checkout.
|
||||
|
||||
Kept as a redirect rather than dropped so bookmarks, and sessions
|
||||
that were mid-flow when the step disappeared, do not hit a 404.
|
||||
"""
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/payment"), allow_redirects=False)
|
||||
|
|
@ -494,8 +498,8 @@ class TestOnlinePaymentRoutes(HttpCase):
|
|||
self.assertEqual(response.status_code, 303)
|
||||
self.assertTrue(response.headers["Location"].endswith("/checkout"))
|
||||
|
||||
def test_payment_page_off_without_online_payment(self):
|
||||
"""The step does not exist for a group order that takes no payments."""
|
||||
def test_legacy_payment_step_redirects_without_online_payment(self):
|
||||
"""Same for a group order that takes no payments at all."""
|
||||
self.group_order.online_payment = False
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
|
@ -505,22 +509,69 @@ class TestOnlinePaymentRoutes(HttpCase):
|
|||
self.assertEqual(response.status_code, 303)
|
||||
self.assertTrue(response.headers["Location"].endswith("/checkout"))
|
||||
|
||||
def test_checkout_offers_payment(self):
|
||||
"""The checkout button turns into the 'confirm and pay' variant.
|
||||
|
||||
Asserted on `data-tooltip-key` rather than the label: the website runs
|
||||
in whatever language the visitor picked, and the label is translated.
|
||||
"""
|
||||
def test_checkout_renders_the_payment_form(self):
|
||||
"""Picking a payment method happens on the checkout, in one screen."""
|
||||
self._publish_a_provider()
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('data-tooltip-key="confirm_and_pay"', response.text)
|
||||
self.assertIn(
|
||||
'id="payment_method"',
|
||||
response.text,
|
||||
"The payment form belongs on the checkout page",
|
||||
)
|
||||
self.assertIn(
|
||||
'data-name="Eskaera Checkout"',
|
||||
response.text,
|
||||
"There must be no separate payment step to redirect to",
|
||||
)
|
||||
|
||||
def test_checkout_summary_comes_from_the_order(self):
|
||||
"""The summary is the order, so it cannot drift from what is charged.
|
||||
|
||||
The cart lives in localStorage while the member shops, and the old
|
||||
client-rendered summary was a snapshot of it taken at page load: a
|
||||
draft reloaded afterwards silently added its own lines back, which
|
||||
only became visible one page later, at payment time.
|
||||
"""
|
||||
self._publish_a_provider()
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.product.name, response.text)
|
||||
self.assertNotIn(
|
||||
'id="checkout-summary-tbody"',
|
||||
response.text,
|
||||
"The summary must not be the client-rendered table any more",
|
||||
)
|
||||
|
||||
def test_checkout_without_a_draft_shows_the_empty_state(self):
|
||||
"""Nothing saved for the cycle: nothing to summarise and nothing to pay."""
|
||||
self._publish_a_provider()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
|
||||
|
||||
# Asserted on markup rather than the empty-state wording: the website
|
||||
# runs in whatever language the visitor picked.
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn(
|
||||
"checkout-summary-table",
|
||||
response.text,
|
||||
"With no order there is nothing to summarise",
|
||||
)
|
||||
self.assertNotIn('id="payment_method"', response.text)
|
||||
|
||||
def test_checkout_keeps_save_draft_without_online_payment(self):
|
||||
"""With the flag off the checkout is exactly what it was."""
|
||||
self.group_order.online_payment = False
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
|
||||
|
|
@ -566,3 +617,120 @@ class TestOnlinePaymentRoutes(HttpCase):
|
|||
|
||||
self.assertEqual(response.status_code, 303)
|
||||
self.assertIn(f"/payment/confirmation/{order.id}", response.headers["Location"])
|
||||
|
||||
def _post_json(self, route, payload):
|
||||
return self.url_open(
|
||||
route,
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
def _save_cart(self, quantity, is_delivery=None):
|
||||
payload = {
|
||||
"order_id": self.group_order.id,
|
||||
"items": [
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"product_name": self.product.name,
|
||||
"quantity": quantity,
|
||||
"product_price": 10.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
if is_delivery is not None:
|
||||
payload["is_delivery"] = is_delivery
|
||||
return self._post_json("/eskaera/save-order", payload)
|
||||
|
||||
def _cycle_drafts(self):
|
||||
return self.env["sale.order"].search(
|
||||
[
|
||||
("partner_id", "=", self.member_partner.id),
|
||||
("group_order_id", "=", self.group_order.id),
|
||||
("state", "=", "draft"),
|
||||
]
|
||||
)
|
||||
|
||||
def test_saving_the_cart_again_updates_the_same_draft(self):
|
||||
"""Pushing the cart reuses the cycle draft rather than adding another.
|
||||
|
||||
The cart reaches the server from the shop's save button and again on
|
||||
the way into the checkout, so this runs on every ordinary flow.
|
||||
"""
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
first = self._save_cart(1)
|
||||
self.assertEqual(first.status_code, 200)
|
||||
order_id = first.json()["sale_order_id"]
|
||||
|
||||
second = self._save_cart(3)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.assertEqual(
|
||||
second.json()["sale_order_id"],
|
||||
order_id,
|
||||
"A second save must update the draft, never create a new one",
|
||||
)
|
||||
|
||||
drafts = self._cycle_drafts()
|
||||
self.assertEqual(len(drafts), 1)
|
||||
self.assertEqual(drafts.order_line.product_uom_qty, 3)
|
||||
|
||||
def test_saving_an_unchanged_cart_leaves_the_lines_alone(self):
|
||||
"""Nothing changed, nothing rewritten.
|
||||
|
||||
Replacing `order_line` unlinks and recreates every line, so an
|
||||
idempotent save would churn their ids for no reason.
|
||||
"""
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
self._save_cart(2)
|
||||
line_ids = self._cycle_drafts().order_line.ids
|
||||
|
||||
self._save_cart(2)
|
||||
|
||||
self.assertEqual(
|
||||
self._cycle_drafts().order_line.ids,
|
||||
line_ids,
|
||||
"An unchanged cart must not rewrite the order lines",
|
||||
)
|
||||
|
||||
def test_home_delivery_toggle_moves_the_line_on_the_order(self):
|
||||
"""The checkout toggle writes to the order, which is what gets charged."""
|
||||
delivery_product = self.env["product.product"].create(
|
||||
{"name": "Home Delivery", "type": "service", "list_price": 5.0}
|
||||
)
|
||||
self.group_order.delivery_product_id = delivery_product
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
self._save_cart(1)
|
||||
|
||||
response = self._post_json(
|
||||
"/eskaera/set-home-delivery",
|
||||
{"order_id": self.group_order.id, "is_delivery": True},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["is_delivery"])
|
||||
draft = self._cycle_drafts()
|
||||
self.assertTrue(draft.home_delivery)
|
||||
self.assertIn(delivery_product, draft.order_line.product_id)
|
||||
|
||||
response = self._post_json(
|
||||
"/eskaera/set-home-delivery",
|
||||
{"order_id": self.group_order.id, "is_delivery": False},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.json()["is_delivery"])
|
||||
draft = self._cycle_drafts()
|
||||
self.assertFalse(draft.home_delivery)
|
||||
self.assertNotIn(delivery_product, draft.order_line.product_id)
|
||||
|
||||
def test_home_delivery_toggle_needs_a_draft(self):
|
||||
"""With nothing saved there is no order to put the delivery line on."""
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self._post_json(
|
||||
"/eskaera/set-home-delivery",
|
||||
{"order_id": self.group_order.id, "is_delivery": True},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
|
|
|||
|
|
@ -129,6 +129,67 @@ class TestStandardPricelistResolution(PricingDelegationCommon, TransactionCase):
|
|||
self.assertEqual(resolved, expected)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestTaxDisplayFollowsTheWebsite(PricingDelegationCommon, TransactionCase):
|
||||
"""Displayed prices honour the website's `Display Product Prices`.
|
||||
|
||||
Eskaera used to hardcode the tax-included display and ignore the setting.
|
||||
Now it follows website_sale, which means a website left on the Odoo default
|
||||
(`tax_excluded`) shows prices without VAT -- correct, but surprising enough
|
||||
to be worth pinning down here.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.pricelist = self._create_pricelist("Display PL")
|
||||
self.tax_21 = self._create_tax("IVA 21%", 21.0)
|
||||
self.product = self._create_product("Display Product", 100.0, self.tax_21)
|
||||
|
||||
def _price(self):
|
||||
return self.controller._get_pricing_info(
|
||||
self.product, self.pricelist, quantity=1.0
|
||||
)
|
||||
|
||||
def test_gross_price_on_a_tax_included_website(self):
|
||||
"""A consumer-facing website quotes what the member pays."""
|
||||
self.website.show_line_subtotals_tax_selection = "tax_included"
|
||||
|
||||
pricing = self._price()
|
||||
|
||||
self.assertAlmostEqual(pricing["price"], 121.0, places=2)
|
||||
self.assertAlmostEqual(pricing["price_unit"], 100.0, places=2)
|
||||
self.assertTrue(pricing["tax_included"])
|
||||
|
||||
def test_net_price_on_a_tax_excluded_website(self):
|
||||
"""A website set to tax-excluded shows the net price."""
|
||||
self.website.show_line_subtotals_tax_selection = "tax_excluded"
|
||||
|
||||
pricing = self._price()
|
||||
|
||||
self.assertAlmostEqual(pricing["price"], 100.0, places=2)
|
||||
self.assertAlmostEqual(pricing["price_unit"], 100.0, places=2)
|
||||
self.assertFalse(pricing["tax_included"])
|
||||
|
||||
def test_the_setting_is_what_moves_the_price(self):
|
||||
"""Nothing but the website setting changes between the two."""
|
||||
self.website.show_line_subtotals_tax_selection = "tax_included"
|
||||
gross = self._price()["price"]
|
||||
|
||||
self.website.show_line_subtotals_tax_selection = "tax_excluded"
|
||||
net = self._price()["price"]
|
||||
|
||||
self.assertAlmostEqual(gross - net, 21.0, places=2)
|
||||
|
||||
def test_batched_listing_follows_the_same_setting(self):
|
||||
"""The listing path must not disagree with the single-product one."""
|
||||
self.website.show_line_subtotals_tax_selection = "tax_excluded"
|
||||
|
||||
batched = self.controller._compute_price_info(self.product, self.pricelist)
|
||||
|
||||
self.assertAlmostEqual(batched[self.product.id]["price"], 100.0, places=2)
|
||||
self.assertFalse(batched[self.product.id]["tax_included"])
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestTaxIncludedFiscalPosition(PricingDelegationCommon, TransactionCase):
|
||||
"""A remapped tax-included tax must rebase the displayed price."""
|
||||
|
|
|
|||
|
|
@ -116,9 +116,9 @@ class TestTemplatesRendering(TransactionCase):
|
|||
# The fix ensures no <t t-set="day_names" t-value="[_(...)]"/> exists
|
||||
# which was causing the NoneType error
|
||||
|
||||
def test_eskaera_checkout_summary_template_exists(self):
|
||||
"""Test that eskaera_checkout_summary sub-template exists."""
|
||||
template = self.env.ref("website_sale_aplicoop.eskaera_checkout_summary")
|
||||
def test_order_lines_summary_template_exists(self):
|
||||
"""The checkout summary sub-template exists and reads the sale.order."""
|
||||
template = self.env.ref("website_sale_aplicoop.eskaera_order_lines_summary")
|
||||
self.assertIsNotNone(template)
|
||||
self.assertEqual(template.type, "qweb")
|
||||
# Verify it has the expected structure
|
||||
|
|
@ -127,6 +127,14 @@ class TestTemplatesRendering(TransactionCase):
|
|||
template.arch_db,
|
||||
"Template must have checkout-summary-table id",
|
||||
)
|
||||
# The summary is server-side on purpose: what it shows is what the
|
||||
# payment form charges, so it can never drift from the localStorage
|
||||
# cart the way the old client-rendered table did.
|
||||
self.assertIn(
|
||||
"sale_order.order_line",
|
||||
template.arch_db,
|
||||
"Template must read the order lines, not a client-side cart",
|
||||
)
|
||||
self.assertIn(
|
||||
"Product",
|
||||
template.arch_db,
|
||||
|
|
@ -137,23 +145,36 @@ class TestTemplatesRendering(TransactionCase):
|
|||
template.arch_db,
|
||||
"Template must have Quantity label for translation",
|
||||
)
|
||||
self.assertIn(
|
||||
"Price", template.arch_db, "Template must have Price label for translation"
|
||||
)
|
||||
self.assertIn(
|
||||
"Subtotal",
|
||||
template.arch_db,
|
||||
"Template must have Subtotal label for translation",
|
||||
)
|
||||
|
||||
def test_eskaera_checkout_summary_renders(self):
|
||||
"""Test that eskaera_checkout_summary renders without errors."""
|
||||
template = self.env.ref("website_sale_aplicoop.eskaera_checkout_summary")
|
||||
# Render the template with empty context
|
||||
html = template._render_template(template.xml_id, {})
|
||||
# Should contain the basic table structure
|
||||
def test_order_lines_summary_renders(self):
|
||||
"""The summary renders the lines and total of a real order."""
|
||||
order = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.supplier.id,
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"product_uom_qty": 2,
|
||||
"price_unit": 5.0,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
template = self.env.ref("website_sale_aplicoop.eskaera_order_lines_summary")
|
||||
|
||||
html = template._render_template(template.xml_id, {"sale_order": order})
|
||||
|
||||
self.assertIn("<table", html)
|
||||
self.assertIn("checkout-summary-table", html)
|
||||
self.assertIn("Product", html)
|
||||
self.assertIn("Quantity", html)
|
||||
self.assertIn("This order's cart is empty", html)
|
||||
self.assertIn("Test Product", html)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,21 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" invisible="not eskaera_enabled">
|
||||
<div class="o_setting_left_pane"/>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="show_line_subtotals_tax_selection" string="Product Prices"/>
|
||||
<div class="text-muted">
|
||||
Eskaera shows the same prices as the shop. Members
|
||||
are consumers, so this is normally "Tax Included".
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="mt16">
|
||||
<field name="show_line_subtotals_tax_selection" class="o_light_label" widget="radio"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Shop Performance</h2>
|
||||
<div class="row mt16 o_settings_container" id="eskaera_shop_settings">
|
||||
|
|
|
|||
|
|
@ -338,7 +338,10 @@
|
|||
<i class="fa fa-truck cart-icon-size" aria-hidden="true" />
|
||||
</button>
|
||||
</t>
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success cart-btn-compact" aria-label="Proceed to Checkout" title="Proceed to Checkout" data-bs-toggle="tooltip">
|
||||
<!-- js-eskaera-checkout: the cart is pushed to the draft
|
||||
sale.order before navigating, so the checkout page can
|
||||
render (and charge) the very lines the member sees. -->
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success cart-btn-compact js-eskaera-checkout" aria-label="Proceed to Checkout" title="Proceed to Checkout" data-bs-toggle="tooltip">
|
||||
<i class="fa fa-check cart-icon-size" aria-hidden="true" />
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger cart-btn-compact" id="clear-cart-btn" t-attf-data-order-id="{{ group_order.id }}" title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
|
||||
|
|
@ -353,7 +356,7 @@
|
|||
<button type="button" class="btn btn-outline-danger btn-sm" id="clear-cart-btn-footer" t-attf-data-order-id="{{ group_order.id }}" title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
|
||||
<i class="fa fa-trash me-1" aria-hidden="true" />Clear Cart
|
||||
</button>
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success checkout-btn-lg" title="Proceed to Checkout" data-bs-toggle="tooltip">
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success checkout-btn-lg js-eskaera-checkout" title="Proceed to Checkout" data-bs-toggle="tooltip">
|
||||
Proceed to Checkout
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -428,41 +431,9 @@
|
|||
</script>
|
||||
</t>
|
||||
</template>
|
||||
<template id="eskaera_checkout_summary" name="Checkout Order Summary">
|
||||
<!-- The table keeps its width and scrolls inside this container; tabindex
|
||||
makes that scroll reachable from the keyboard (WCAG 2.1.1). -->
|
||||
<div class="checkout-summary-container" role="region" tabindex="0" aria-label="Order summary">
|
||||
<table class="table table-hover checkout-summary-table" id="checkout-summary-table">
|
||||
<caption class="visually-hidden">Products in this order, with quantity, price and subtotal</caption>
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th scope="col" class="col-name">Product</th>
|
||||
<th scope="col" class="col-qty text-center">Quantity</th>
|
||||
<th scope="col" class="col-price text-end">Price</th>
|
||||
<th scope="col" class="col-subtotal text-end">Subtotal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="checkout-summary-tbody">
|
||||
<tr id="checkout-empty-row" class="empty-message">
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
<i class="fa fa-inbox fa-2x mb-2" aria-hidden="true" />
|
||||
<p>This order's cart is empty</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="checkout-total-section">
|
||||
<div class="total-row">
|
||||
<span class="total-label">Total</span>:
|
||||
<span class="total-amount" id="checkout-total-amount">0.00</span>
|
||||
<span class="currency">€</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template id="eskaera_checkout" name="Eskaera Checkout">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Checkout" t-attf-data-delivery-product-id="{{ delivery_product_id }}" t-attf-data-delivery-product-name="{{ delivery_product_name }}" t-attf-data-delivery-product-price="{{ delivery_product_price }}" t-attf-data-home-delivery-enabled="{{ 'true' if group_order.home_delivery else 'false' }}" t-attf-data-pickup-day="{{ group_order.pickup_day }}" t-attf-data-pickup-date="{{ group_order.pickup_date.strftime('%d/%m/%Y') if group_order.pickup_date else '' }}" t-attf-data-next-pickup-slot-label="{{ group_order.next_pickup_slot_id.label if group_order.next_pickup_slot_id else '' }}" t-attf-data-delivery-notice="{{ (group_order.delivery_notice or '').replace(chr(10), ' ').replace(chr(13), ' ') }}">
|
||||
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Checkout" t-attf-data-order-id="{{ group_order.id }}" t-attf-data-delivery-product-id="{{ delivery_product_id }}" t-attf-data-delivery-product-name="{{ delivery_product_name }}" t-attf-data-delivery-product-price="{{ delivery_product_price }}" t-attf-data-home-delivery-enabled="{{ 'true' if group_order.home_delivery else 'false' }}" t-attf-data-pickup-day="{{ group_order.pickup_day }}" t-attf-data-pickup-date="{{ group_order.pickup_date.strftime('%d/%m/%Y') if group_order.pickup_date else '' }}" t-attf-data-next-pickup-slot-label="{{ group_order.next_pickup_slot_id.label if group_order.next_pickup_slot_id else '' }}" t-attf-data-delivery-notice="{{ (group_order.delivery_notice or '').replace(chr(10), ' ').replace(chr(13), ' ') }}">
|
||||
<div class="container mt-5">
|
||||
<div class="row">
|
||||
<div class="col-lg-10 offset-lg-1">
|
||||
|
|
@ -536,49 +507,122 @@
|
|||
</div>
|
||||
<h4 class="summary-heading mb-3">Order Summary</h4>
|
||||
<div class="oe_structure oe_empty mb-3" data-name="Before Summary" />
|
||||
<!-- The summary comes from the saved sale.order, never from
|
||||
the localStorage cart: these are the very lines and
|
||||
amounts the payment form is about to charge, so the two
|
||||
cannot drift apart. The shop pushes the cart here before
|
||||
sending the member over. -->
|
||||
<div id="checkout-summary" class="mb-5">
|
||||
<t t-call="website_sale_aplicoop.eskaera_checkout_summary">
|
||||
<t t-set="labels" t-value="{}" />
|
||||
<t t-if="sale_order">
|
||||
<t t-call="website_sale_aplicoop.eskaera_order_lines_summary" />
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-info" role="status">
|
||||
<i class="fa fa-inbox me-2" aria-hidden="true" t-translation="off" />
|
||||
<span>This order's cart is empty</span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="oe_structure oe_empty mb-4" data-name="After Summary" />
|
||||
<t t-if="group_order.home_delivery">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="form-check eskaera-delivery-check">
|
||||
<input type="checkbox" class="form-check-input" id="home-delivery-checkbox" name="home_delivery" />
|
||||
<label class="form-check-label fw-bold" for="home-delivery-checkbox">Home Delivery</label>
|
||||
<t t-if="sale_order">
|
||||
<t t-if="group_order.home_delivery">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="form-check eskaera-delivery-check">
|
||||
<input type="checkbox" class="form-check-input" id="home-delivery-checkbox" name="home_delivery" t-att-checked="sale_order.home_delivery or None" />
|
||||
<label class="form-check-label fw-bold" for="home-delivery-checkbox">Home Delivery</label>
|
||||
</div>
|
||||
<div t-attf-class="alert alert-info mt-3 eskaera-delivery-notice{{ '' if sale_order.home_delivery else ' d-none' }}" id="delivery-info-alert">
|
||||
<p class="mb-2">
|
||||
<i class="fa fa-truck" aria-hidden="true" t-translation="off" />
|
||||
<t t-if="group_order.delivery_date">
|
||||
<strong>Delivery Information:</strong> Your order will be delivered at
|
||||
<t t-esc="day_names[(int(group_order.pickup_day) + 1) % 7]" />
|
||||
<t t-esc="group_order.delivery_date.strftime('%d/%m/%Y')" />
|
||||
<t t-if="group_order.delivery_notice">
|
||||
<br />
|
||||
<t t-esc="group_order.delivery_notice" />
|
||||
</t>
|
||||
</t>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="delivery-info-alert" class="alert alert-info mt-3 d-none eskaera-delivery-notice">
|
||||
<p class="mb-2">
|
||||
<i class="fa fa-truck" aria-hidden="true" t-translation="off" />
|
||||
<t t-if="group_order.delivery_date">
|
||||
<strong>Delivery Information:</strong> Your order will be delivered at
|
||||
<t t-esc="day_names[(int(group_order.pickup_day) + 1) % 7]" />
|
||||
<t t-esc="group_order.delivery_date.strftime('%d/%m/%Y')" />
|
||||
<t t-if="group_order.delivery_notice">
|
||||
<br />
|
||||
<t t-esc="group_order.delivery_notice" />
|
||||
</t>
|
||||
</t>
|
||||
</p>
|
||||
</t>
|
||||
<t t-if="online_payment">
|
||||
<t t-if="pending_transaction">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h5 class="alert-heading">
|
||||
<i class="fa fa-clock-o me-2" aria-hidden="true" t-translation="off" />
|
||||
<span>Payment in progress</span>
|
||||
</h5>
|
||||
<p class="mb-1">We are still waiting for your payment to be confirmed. Please do not pay again.</p>
|
||||
<p class="mb-0">
|
||||
<span>Reference</span>:
|
||||
<span t-esc="pending_transaction.reference" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="checkout-actions d-grid gap-3">
|
||||
<a t-att-href="sale_order.get_portal_url()" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="fa fa-file-text-o" aria-hidden="true" t-translation="off" />
|
||||
<span>View my order</span>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="payment_ready and payment_available">
|
||||
<h4 class="summary-heading mb-3">Payment Method</h4>
|
||||
<!-- The whole provider UI is payment.form; this addon
|
||||
configures no provider of its own. -->
|
||||
<div id="payment_method" class="o_not_editable mb-4">
|
||||
<t t-call="payment.form" />
|
||||
</div>
|
||||
<!-- Deliberately outside #o_payment_form: website_sale's
|
||||
payment_form.js binds every
|
||||
[name="o_payment_submit_button"] in the document, on
|
||||
top of payment_form.js's own delegated handler inside
|
||||
the form. A button inside would get both listeners and
|
||||
one click would open two transactions. -->
|
||||
<div class="checkout-actions d-grid gap-3">
|
||||
<t t-call="payment.submit_button" />
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
||||
<span>Back to Cart</span>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-warning" role="alert" t-esc="no_payment_method_message" />
|
||||
<div class="checkout-actions d-grid gap-3">
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
||||
<span>Back to Cart</span>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<!-- No online payment: the draft is confirmed in bulk by
|
||||
the cutoff cron, so the button only re-saves the cart. -->
|
||||
<div class="checkout-actions d-grid gap-3" id="checkout-form-labels">
|
||||
<button class="btn btn-success btn-lg" id="confirm-order-btn" t-attf-data-order-id="{{ group_order.id }}" t-att-data-confirmed-label="checkout_button['done_label']" t-att-data-pickup-label="labels.get('pickup_day_label', 'Pickup Day')" t-att-aria-label="checkout_button['hint']" t-att-data-bs-title="checkout_button['label']" t-att-data-tooltip-key="checkout_button['tooltip_key']" data-bs-toggle="tooltip">
|
||||
<i t-attf-class="fa {{ checkout_button['icon'] }}" aria-hidden="true" t-translation="off" />
|
||||
<span t-esc="checkout_button['label']" />
|
||||
</button>
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
||||
<span>Back to Cart</span>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="checkout-actions d-grid gap-3">
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-success btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
||||
<span>Back to Cart</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="online_payment and not payment_available">
|
||||
<div class="alert alert-warning" role="alert" t-esc="no_payment_method_message" />
|
||||
</t>
|
||||
<div class="checkout-actions d-grid gap-3" id="checkout-form-labels">
|
||||
<button class="btn btn-success btn-lg" id="confirm-order-btn" t-attf-data-order-id="{{ group_order.id }}" t-att-data-confirmed-label="checkout_button['done_label']" t-att-data-pickup-label="labels.get('pickup_day_label', 'Pickup Day')" t-att-aria-label="checkout_button['hint']" t-att-data-bs-title="checkout_button['label']" t-att-data-tooltip-key="checkout_button['tooltip_key']" t-att-disabled="online_payment and not payment_available" data-bs-toggle="tooltip">
|
||||
<i t-attf-class="fa {{ checkout_button['icon'] }}" aria-hidden="true" t-translation="off" />
|
||||
<span t-esc="checkout_button['label']" />
|
||||
</button>
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
||||
<span>Back to Cart</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -593,45 +637,14 @@
|
|||
console.log('[LABELS] Initialized from server:', window.groupOrderShop.labels);
|
||||
})();
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
// Auto-load cart from localStorage when accessing checkout directly
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Get order ID from button
|
||||
var confirmBtn = document.getElementById('confirm-order-btn');
|
||||
if (!confirmBtn) return;
|
||||
|
||||
var orderId = confirmBtn.getAttribute('data-order-id');
|
||||
var cartKey = 'eskaera_' + orderId + '_cart';
|
||||
|
||||
// Check if there's a saved cart and load it
|
||||
var savedCart = localStorage.getItem(cartKey);
|
||||
if (savedCart) {
|
||||
try {
|
||||
var cart = JSON.parse(savedCart);
|
||||
console.log('[CHECKOUT AUTO-LOAD] Cart found in localStorage:', cart);
|
||||
|
||||
// Simulate cart loading by triggering a custom event
|
||||
// The checkout_labels.js will listen for cart data
|
||||
var event = new CustomEvent('cartLoaded', { detail: { cart: cart } });
|
||||
document.dispatchEvent(event);
|
||||
} catch (e) {
|
||||
console.error('[CHECKOUT AUTO-LOAD] Error parsing cart:', e);
|
||||
}
|
||||
} else {
|
||||
console.log('[CHECKOUT AUTO-LOAD] No cart found in localStorage');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
<template id="eskaera_order_lines_summary" name="Placed Order Summary">
|
||||
<!-- Server-side twin of eskaera_checkout_summary: from the payment
|
||||
step on, the amounts shown must be the ones that will be
|
||||
charged, so they come from the sale.order and not from the
|
||||
localStorage cart. -->
|
||||
<!-- The order summary of every page past the shop. The cart is a
|
||||
localStorage object while the member shops, but from the
|
||||
checkout on the amounts shown must be the ones that will be
|
||||
charged, so they come from the sale.order. -->
|
||||
<div class="checkout-summary-container" role="region" tabindex="0" aria-label="Order summary">
|
||||
<table class="table table-hover checkout-summary-table">
|
||||
<caption class="visually-hidden">Products in this order, with quantity, price and subtotal</caption>
|
||||
|
|
@ -662,69 +675,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template id="eskaera_payment" name="Eskaera Payment">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Payment">
|
||||
<div class="container mt-5">
|
||||
<div class="row">
|
||||
<div class="col-lg-10 offset-lg-1">
|
||||
<div class="mb-4">
|
||||
<t t-call="website_sale_aplicoop.order_header">
|
||||
<t t-set="header_class" t-value="'checkout-header'" />
|
||||
<t t-set="header_title">Pay Order: <t t-esc="group_order.name" />
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<h4 class="summary-heading mb-3">Order Summary</h4>
|
||||
<div class="mb-5">
|
||||
<t t-call="website_sale_aplicoop.eskaera_order_lines_summary" />
|
||||
</div>
|
||||
<t t-if="pending_transaction">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h5 class="alert-heading">
|
||||
<i class="fa fa-clock-o me-2" aria-hidden="true" t-translation="off" />
|
||||
<span>Payment in progress</span>
|
||||
</h5>
|
||||
<p class="mb-1">We are still waiting for your payment to be confirmed. Please do not pay again.</p>
|
||||
<p class="mb-0">
|
||||
<span>Reference</span>:
|
||||
<span t-esc="pending_transaction.reference" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="checkout-actions d-grid gap-3">
|
||||
<a t-att-href="sale_order.get_portal_url()" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="fa fa-file-text-o" aria-hidden="true" t-translation="off" />
|
||||
<span>View my order</span>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<h4 class="summary-heading mb-3">Payment Method</h4>
|
||||
<!-- The whole provider UI is payment.form; this addon
|
||||
configures no provider of its own. -->
|
||||
<div id="payment_method" class="o_not_editable mb-4">
|
||||
<t t-call="payment.form" />
|
||||
</div>
|
||||
<!-- Deliberately outside #o_payment_form: website_sale's
|
||||
payment_form.js binds every
|
||||
[name="o_payment_submit_button"] in the document, on
|
||||
top of payment_form.js's own delegated handler inside
|
||||
the form. A button inside would get both listeners and
|
||||
one click would open two transactions. -->
|
||||
<div class="checkout-actions d-grid gap-3">
|
||||
<t t-call="payment.submit_button" />
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
||||
<span>Back to Checkout</span>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
<template id="eskaera_payment_confirmation" name="Eskaera Payment Confirmation">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Payment Confirmation" t-attf-data-clear-cart-order-id="{{ group_order.id }}">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue